All files / src/components/dag useDagLayout.ts

70.73% Statements 29/41
41.66% Branches 5/12
66.66% Functions 4/6
71.79% Lines 28/39

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140            3x   3x   3x   3x     3x   3x                                           6x 6x 3x         3x 3x 3x           5x     3x 3x 5x               3x 5x       3x   3x   5x                             5x                                           3x     3x 5x 5x   5x                               3x      
import { useMemo } from "react";
import dagre from "@dagrejs/dagre";
import type { Node, Edge } from "@xyflow/react";
import type { TaskData } from "../../hooks/types.js";
 
/** Width of each task node in the DAG layout (pixels). */
const NODE_WIDTH: number = 220;
/** Height of each task node in the DAG layout (pixels). */
const NODE_HEIGHT: number = 70;
/** Horizontal separation between sibling nodes (pixels). */
const NODE_SEPARATION: number = 40;
/** Vertical separation between rank levels (pixels). */
const RANK_SEPARATION: number = 60;
 
/** Edge type identifier for parent→child (hierarchy) edges. */
const EDGE_TYPE_HIERARCHY: string = "hierarchy";
/** Edge type identifier for dependency edges. */
const EDGE_TYPE_DEPENDENCY: string = "dependency";
 
/** Data attached to each React Flow task node. */
export interface TaskNodeData extends Record<string, unknown> {
  task: TaskData;
  childCount: number;
  doneChildCount: number;
  hasDependencies: boolean;
}
 
/** Result of the DAG layout computation. */
export interface DagLayoutResult {
  nodes: Node<TaskNodeData>[];
  edges: Edge[];
}
 
/**
 * Computes a dagre-based DAG layout from a flat list of tasks.
 * Produces positioned React Flow nodes and edges for both hierarchy
 * (parent→child) and dependency relationships.
 */
export function useDagLayout(tasks: TaskData[]): DagLayoutResult {
  return useMemo(() => {
    if (tasks.length === 0) {
      return { nodes: [], edges: [] };
    }
 
    // Enable multigraph so hierarchy and dependency edges between the same
    // pair of nodes are both preserved in the layout graph.
    const graph = new dagre.graphlib.Graph({ multigraph: true });
    graph.setDefaultEdgeLabel(() => ({}));
    graph.setGraph({
      rankdir: "TB",
      nodesep: NODE_SEPARATION,
      ranksep: RANK_SEPARATION,
    });
 
    const taskById = new Map(tasks.map((t) => [t.id, t]));
 
    // Precompute children per parent to avoid O(n^2) lookups when building nodes.
    const childrenByParent = new Map<string, TaskData[]>();
    for (const task of tasks) {
      Iif (task.parentTaskId && taskById.has(task.parentTaskId)) {
        const siblings = childrenByParent.get(task.parentTaskId) || [];
        siblings.push(task);
        childrenByParent.set(task.parentTaskId, siblings);
      }
    }
 
    // Add nodes
    for (const task of tasks) {
      graph.setNode(task.id, { width: NODE_WIDTH, height: NODE_HEIGHT });
    }
 
    // Add edges
    const edges: Edge[] = [];
 
    for (const task of tasks) {
      // Parent → child edges
      Iif (task.parentTaskId && taskById.has(task.parentTaskId)) {
        const edgeId = `hierarchy-${task.parentTaskId}-${task.id}`;
        graph.setEdge(task.parentTaskId, task.id, {}, edgeId);
        edges.push({
          id: edgeId,
          source: task.parentTaskId,
          target: task.id,
          type: "smoothstep",
          data: { edgeType: EDGE_TYPE_HIERARCHY },
          style: { stroke: "var(--accent-green)", strokeWidth: 2 },
          animated: false,
        });
      }
 
      // Dependency edges
      for (const depId of task.dependsOn) {
        Iif (taskById.has(depId)) {
          const edgeId = `dependency-${depId}-${task.id}`;
          graph.setEdge(depId, task.id, {}, edgeId);
          edges.push({
            id: edgeId,
            source: depId,
            target: task.id,
            type: "smoothstep",
            data: { edgeType: EDGE_TYPE_DEPENDENCY },
            style: {
              stroke: "var(--text-tertiary)",
              strokeWidth: 1.5,
              strokeDasharray: "6 3",
            },
            animated: false,
          });
        }
      }
    }
 
    // Run dagre layout
    dagre.layout(graph);
 
    // Map dagre positions to React Flow nodes
    const nodes: Node<TaskNodeData>[] = tasks.map((task) => {
      const nodeWithPosition = graph.node(task.id) as { x: number; y: number };
      const children = childrenByParent.get(task.id) || [];
 
      return {
        id: task.id,
        type: "task",
        position: {
          x: nodeWithPosition.x - NODE_WIDTH / 2,
          y: nodeWithPosition.y - NODE_HEIGHT / 2,
        },
        data: {
          task,
          childCount: children.length,
          doneChildCount: children.filter((c) => c.status === "complete").length,
          hasDependencies: task.dependsOn.length > 0,
        },
      };
    });
 
    return { nodes, edges };
  }, [tasks]);
}