feat(#15): implement gantt chart component
- Add milestone support with diamond markers - Implement dependency line rendering with SVG arrows - Add isMilestone property to GanttTask type - Create dependency calculation and visualization - Add comprehensive tests for milestones and dependencies - Add index module tests for exports - Coverage: GanttChart 98.37%, types 91.66%, index 100%
This commit is contained in:
@@ -5,6 +5,18 @@ import { TaskStatus } from "@mosaic/shared";
|
||||
import { formatDate, isPastTarget, isApproachingTarget } from "@/lib/utils/date-format";
|
||||
import { useMemo } from "react";
|
||||
|
||||
/**
|
||||
* Represents a dependency line between two tasks
|
||||
*/
|
||||
interface DependencyLine {
|
||||
fromTaskId: string;
|
||||
toTaskId: string;
|
||||
fromX: number;
|
||||
fromY: number;
|
||||
toX: number;
|
||||
toY: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate the timeline range from a list of tasks
|
||||
*/
|
||||
@@ -135,6 +147,65 @@ function generateTimelineLabels(range: TimelineRange): Array<{ label: string; po
|
||||
return labels;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate dependency lines between tasks
|
||||
*/
|
||||
function calculateDependencyLines(
|
||||
tasks: GanttTask[],
|
||||
timelineRange: TimelineRange
|
||||
): DependencyLine[] {
|
||||
const lines: DependencyLine[] = [];
|
||||
const taskIndexMap = new Map<string, number>();
|
||||
|
||||
// Build index map
|
||||
tasks.forEach((task, index) => {
|
||||
taskIndexMap.set(task.id, index);
|
||||
});
|
||||
|
||||
const { start: rangeStart, totalDays } = timelineRange;
|
||||
|
||||
tasks.forEach((task, toIndex) => {
|
||||
if (!task.dependencies || task.dependencies.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
task.dependencies.forEach((depId) => {
|
||||
const fromIndex = taskIndexMap.get(depId);
|
||||
if (fromIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const fromTask = tasks[fromIndex];
|
||||
|
||||
// Calculate positions (as percentages)
|
||||
const fromEndOffset = Math.max(
|
||||
0,
|
||||
(fromTask.endDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
const toStartOffset = Math.max(
|
||||
0,
|
||||
(task.startDate.getTime() - rangeStart.getTime()) / (1000 * 60 * 60 * 24)
|
||||
);
|
||||
|
||||
const fromX = (fromEndOffset / totalDays) * 100;
|
||||
const toX = (toStartOffset / totalDays) * 100;
|
||||
const fromY = fromIndex * 48 + 24; // Center of the row
|
||||
const toY = toIndex * 48 + 24;
|
||||
|
||||
lines.push({
|
||||
fromTaskId: depId,
|
||||
toTaskId: task.id,
|
||||
fromX,
|
||||
fromY,
|
||||
toX,
|
||||
toY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
return lines;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main Gantt Chart Component
|
||||
*/
|
||||
@@ -155,6 +226,12 @@ export function GanttChart({
|
||||
// Generate timeline labels
|
||||
const timelineLabels = useMemo(() => generateTimelineLabels(timelineRange), [timelineRange]);
|
||||
|
||||
// Calculate dependency lines
|
||||
const dependencyLines = useMemo(
|
||||
() => (showDependencies ? calculateDependencyLines(sortedTasks, timelineRange) : []),
|
||||
[showDependencies, sortedTasks, timelineRange]
|
||||
);
|
||||
|
||||
const handleTaskClick = (task: GanttTask) => (): void => {
|
||||
if (onTaskClick) {
|
||||
onTaskClick(task);
|
||||
@@ -242,11 +319,68 @@ export function GanttChart({
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Task bars */}
|
||||
{/* Dependency lines SVG */}
|
||||
{showDependencies && dependencyLines.length > 0 && (
|
||||
<svg
|
||||
className="gantt-dependencies absolute inset-0 pointer-events-none overflow-visible"
|
||||
style={{ width: "100%", height: `${sortedTasks.length * 48}px` }}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<defs>
|
||||
<marker
|
||||
id="arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="9"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
>
|
||||
<polygon points="0 0, 10 3.5, 0 7" fill="#6b7280" />
|
||||
</marker>
|
||||
</defs>
|
||||
{dependencyLines.map((line) => (
|
||||
<path
|
||||
key={`dep-${line.fromTaskId}-${line.toTaskId}`}
|
||||
d={`M ${line.fromX}% ${line.fromY} C ${line.fromX + 2}% ${line.fromY}, ${line.toX - 2}% ${line.toY}, ${line.toX}% ${line.toY}`}
|
||||
stroke="#6b7280"
|
||||
strokeWidth="2"
|
||||
fill="none"
|
||||
markerEnd="url(#arrowhead)"
|
||||
className="dependency-line"
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
)}
|
||||
|
||||
{/* Task bars and milestones */}
|
||||
{sortedTasks.map((task, index) => {
|
||||
const position = calculateBarPosition(task, timelineRange, index);
|
||||
const statusClass = getStatusClass(task.status);
|
||||
|
||||
// Render milestone as diamond shape
|
||||
if (task.isMilestone === true) {
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`Milestone: ${task.title}, on ${formatDate(task.startDate)}`}
|
||||
className="gantt-milestone absolute cursor-pointer transition-all hover:opacity-80 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
style={{
|
||||
left: position.left,
|
||||
top: `${position.top + 8}px`,
|
||||
}}
|
||||
onClick={handleTaskClick(task)}
|
||||
onKeyDown={handleKeyDown(task)}
|
||||
>
|
||||
<div
|
||||
className={`w-6 h-6 transform rotate-45 ${statusClass}`}
|
||||
title={task.title}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
key={task.id}
|
||||
|
||||
Reference in New Issue
Block a user