From b930091c42e1b8e9b0a2573459e8a4e49fe8d2e7 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 22 Dec 2025 19:10:32 +0000 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20feat:=20add=20dependency=20graph=20?= =?UTF-8?q?view=20for=20task=20visualization?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new interactive graph view alongside the kanban board for visualizing task dependencies. The graph view uses React Flow with dagre auto-layout to display tasks as nodes connected by dependency edges. Key features: - Toggle between kanban and graph view via new control buttons - Custom TaskNode component matching existing card styling/themes - Animated edges that flow when tasks are in progress - Status-aware node colors (backlog, in-progress, waiting, verified) - Blocked tasks show lock icon with dependency count tooltip - MiniMap for navigation in large graphs - Zoom, pan, fit-view, and lock controls - Horizontal/vertical layout options via dagre - Click node to view details, double-click to edit - Respects all 32 themes via CSS variables - Reduced motion support for animations New dependencies: @xyflow/react, dagre --- apps/ui/package.json | 3 + apps/ui/src/components/views/board-view.tsx | 81 +++--- .../views/board-view/board-controls.tsx | 52 +++- .../graph-view/components/dependency-edge.tsx | 115 ++++++++ .../graph-view/components/graph-controls.tsx | 144 ++++++++++ .../graph-view/components/graph-legend.tsx | 69 +++++ .../views/graph-view/components/index.ts | 4 + .../views/graph-view/components/task-node.tsx | 248 +++++++++++++++++ .../views/graph-view/graph-canvas.tsx | 174 ++++++++++++ .../views/graph-view/graph-view.tsx | 89 +++++++ .../views/graph-view/hooks/index.ts | 2 + .../graph-view/hooks/use-graph-layout.ts | 93 +++++++ .../views/graph-view/hooks/use-graph-nodes.ts | 79 ++++++ .../src/components/views/graph-view/index.ts | 4 + apps/ui/src/store/app-store.ts | 7 + apps/ui/src/styles/global.css | 159 +++++++++++ package-lock.json | 252 +++++++++++++++++- 17 files changed, 1540 insertions(+), 35 deletions(-) create mode 100644 apps/ui/src/components/views/graph-view/components/dependency-edge.tsx create mode 100644 apps/ui/src/components/views/graph-view/components/graph-controls.tsx create mode 100644 apps/ui/src/components/views/graph-view/components/graph-legend.tsx create mode 100644 apps/ui/src/components/views/graph-view/components/index.ts create mode 100644 apps/ui/src/components/views/graph-view/components/task-node.tsx create mode 100644 apps/ui/src/components/views/graph-view/graph-canvas.tsx create mode 100644 apps/ui/src/components/views/graph-view/graph-view.tsx create mode 100644 apps/ui/src/components/views/graph-view/hooks/index.ts create mode 100644 apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts create mode 100644 apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts create mode 100644 apps/ui/src/components/views/graph-view/index.ts diff --git a/apps/ui/package.json b/apps/ui/package.json index 8227deed..9523d877 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -63,9 +63,11 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre": "^0.8.5", "dotenv": "^17.2.3", "geist": "^1.5.1", "lucide-react": "^0.562.0", @@ -94,6 +96,7 @@ "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/router-plugin": "^1.141.7", + "@types/dagre": "^0.7.53", "@types/node": "^22", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 520dc6e4..670301a8 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -21,6 +21,7 @@ import { BoardHeader } from './board-view/board-header'; import { BoardSearchBar } from './board-view/board-search-bar'; import { BoardControls } from './board-view/board-controls'; import { KanbanBoard } from './board-view/kanban-board'; +import { GraphView } from './graph-view'; import { AddFeatureDialog, AgentOutputModal, @@ -69,6 +70,8 @@ export function BoardView() { aiProfiles, kanbanCardDetailLevel, setKanbanCardDetailLevel, + boardViewMode, + setBoardViewMode, specCreatingForProject, setSpecCreatingForProject, pendingPlanApproval, @@ -989,40 +992,54 @@ export function BoardView() { completedCount={completedFeatures.length} kanbanCardDetailLevel={kanbanCardDetailLevel} onDetailLevelChange={setKanbanCardDetailLevel} + boardViewMode={boardViewMode} + onBoardViewModeChange={setBoardViewMode} /> - {/* Kanban Columns */} - setEditingFeature(feature)} - onDelete={(featureId) => handleDeleteFeature(featureId)} - onViewOutput={handleViewOutput} - onVerify={handleVerifyFeature} - onResume={handleResumeFeature} - onForceStop={handleForceStopFeature} - onManualVerify={handleManualVerify} - onMoveBackToInProgress={handleMoveBackToInProgress} - onFollowUp={handleOpenFollowUp} - onCommit={handleCommitFeature} - onComplete={handleCompleteFeature} - onImplement={handleStartImplementation} - onViewPlan={(feature) => setViewPlanFeature(feature)} - onApprovePlan={handleOpenApprovalDialog} - featuresWithContext={featuresWithContext} - runningAutoTasks={runningAutoTasks} - shortcuts={shortcuts} - onStartNextFeatures={handleStartNextFeatures} - onShowSuggestions={() => setShowSuggestionsDialog(true)} - suggestionsCount={suggestionsCount} - onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} - /> + {/* View Content - Kanban or Graph */} + {boardViewMode === 'kanban' ? ( + setEditingFeature(feature)} + onDelete={(featureId) => handleDeleteFeature(featureId)} + onViewOutput={handleViewOutput} + onVerify={handleVerifyFeature} + onResume={handleResumeFeature} + onForceStop={handleForceStopFeature} + onManualVerify={handleManualVerify} + onMoveBackToInProgress={handleMoveBackToInProgress} + onFollowUp={handleOpenFollowUp} + onCommit={handleCommitFeature} + onComplete={handleCompleteFeature} + onImplement={handleStartImplementation} + onViewPlan={(feature) => setViewPlanFeature(feature)} + onApprovePlan={handleOpenApprovalDialog} + featuresWithContext={featuresWithContext} + runningAutoTasks={runningAutoTasks} + shortcuts={shortcuts} + onStartNextFeatures={handleStartNextFeatures} + onShowSuggestions={() => setShowSuggestionsDialog(true)} + suggestionsCount={suggestionsCount} + onArchiveAllVerified={() => setShowArchiveAllVerifiedDialog(true)} + /> + ) : ( + setEditingFeature(feature)} + onViewOutput={handleViewOutput} + /> + )} {/* Board Background Modal */} diff --git a/apps/ui/src/components/views/board-view/board-controls.tsx b/apps/ui/src/components/views/board-view/board-controls.tsx index 92d58b06..14733637 100644 --- a/apps/ui/src/components/views/board-view/board-controls.tsx +++ b/apps/ui/src/components/views/board-view/board-controls.tsx @@ -1,7 +1,8 @@ import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; -import { ImageIcon, Archive, Minimize2, Square, Maximize2 } from 'lucide-react'; +import { ImageIcon, Archive, Minimize2, Square, Maximize2, Columns3, Network } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { BoardViewMode } from '@/store/app-store'; interface BoardControlsProps { isMounted: boolean; @@ -10,6 +11,8 @@ interface BoardControlsProps { completedCount: number; kanbanCardDetailLevel: 'minimal' | 'standard' | 'detailed'; onDetailLevelChange: (level: 'minimal' | 'standard' | 'detailed') => void; + boardViewMode: BoardViewMode; + onBoardViewModeChange: (mode: BoardViewMode) => void; } export function BoardControls({ @@ -19,12 +22,59 @@ export function BoardControls({ completedCount, kanbanCardDetailLevel, onDetailLevelChange, + boardViewMode, + onBoardViewModeChange, }: BoardControlsProps) { if (!isMounted) return null; return (
+ {/* View Mode Toggle - Kanban / Graph */} +
+ + + + + +

Kanban Board View

+
+
+ + + + + +

Dependency Graph View

+
+
+
+ {/* Board Background Button */} diff --git a/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx new file mode 100644 index 00000000..ad0ba3bd --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/dependency-edge.tsx @@ -0,0 +1,115 @@ +import { memo } from 'react'; +import { BaseEdge, getBezierPath, EdgeLabelRenderer } from '@xyflow/react'; +import type { EdgeProps } from '@xyflow/react'; +import { cn } from '@/lib/utils'; +import { Feature } from '@/store/app-store'; + +export interface DependencyEdgeData { + sourceStatus: Feature['status']; + targetStatus: Feature['status']; +} + +const getEdgeColor = (sourceStatus?: Feature['status'], targetStatus?: Feature['status']) => { + // If source is completed/verified, the dependency is satisfied + if (sourceStatus === 'completed' || sourceStatus === 'verified') { + return 'var(--status-success)'; + } + // If target is in progress, show active color + if (targetStatus === 'in_progress') { + return 'var(--status-in-progress)'; + } + // If target is blocked (in backlog with incomplete deps) + if (targetStatus === 'backlog') { + return 'var(--border)'; + } + // Default + return 'var(--border)'; +}; + +export const DependencyEdge = memo(function DependencyEdge(props: EdgeProps) { + const { + id, + sourceX, + sourceY, + targetX, + targetY, + sourcePosition, + targetPosition, + data, + selected, + animated, + } = props; + + const edgeData = data as DependencyEdgeData | undefined; + + const [edgePath, labelX, labelY] = getBezierPath({ + sourceX, + sourceY, + sourcePosition, + targetX, + targetY, + targetPosition, + curvature: 0.25, + }); + + const edgeColor = edgeData + ? getEdgeColor(edgeData.sourceStatus, edgeData.targetStatus) + : 'var(--border)'; + + const isCompleted = edgeData?.sourceStatus === 'completed' || edgeData?.sourceStatus === 'verified'; + const isInProgress = edgeData?.targetStatus === 'in_progress'; + + return ( + <> + {/* Background edge for better visibility */} + + + {/* Main edge */} + + + {/* Animated particles for in-progress edges */} + {animated && ( + +
+
+
+ + )} + + ); +}); diff --git a/apps/ui/src/components/views/graph-view/components/graph-controls.tsx b/apps/ui/src/components/views/graph-view/components/graph-controls.tsx new file mode 100644 index 00000000..c2849a33 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/graph-controls.tsx @@ -0,0 +1,144 @@ +import { useReactFlow, Panel } from '@xyflow/react'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; +import { + ZoomIn, + ZoomOut, + Maximize2, + Lock, + Unlock, + GitBranch, + ArrowRight, + ArrowDown, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +interface GraphControlsProps { + isLocked: boolean; + onToggleLock: () => void; + onRunLayout: (direction: 'LR' | 'TB') => void; + layoutDirection: 'LR' | 'TB'; +} + +export function GraphControls({ + isLocked, + onToggleLock, + onRunLayout, + layoutDirection, +}: GraphControlsProps) { + const { zoomIn, zoomOut, fitView } = useReactFlow(); + + return ( + + +
+ {/* Zoom controls */} + + + + + Zoom In + + + + + + + Zoom Out + + + + + + + Fit View + + +
+ + {/* Layout controls */} + + + + + Horizontal Layout + + + + + + + Vertical Layout + + +
+ + {/* Lock toggle */} + + + + + + {isLocked ? 'Unlock Nodes' : 'Lock Nodes'} + + +
+ + + ); +} diff --git a/apps/ui/src/components/views/graph-view/components/graph-legend.tsx b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx new file mode 100644 index 00000000..93a3a497 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/graph-legend.tsx @@ -0,0 +1,69 @@ +import { Panel } from '@xyflow/react'; +import { + Clock, + Play, + Pause, + CheckCircle2, + Lock, + AlertCircle, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; + +const legendItems = [ + { + icon: Clock, + label: 'Backlog', + colorClass: 'text-muted-foreground', + bgClass: 'bg-muted/50', + }, + { + icon: Play, + label: 'In Progress', + colorClass: 'text-[var(--status-in-progress)]', + bgClass: 'bg-[var(--status-in-progress)]/20', + }, + { + icon: Pause, + label: 'Waiting', + colorClass: 'text-[var(--status-waiting)]', + bgClass: 'bg-[var(--status-warning)]/20', + }, + { + icon: CheckCircle2, + label: 'Verified', + colorClass: 'text-[var(--status-success)]', + bgClass: 'bg-[var(--status-success)]/20', + }, + { + icon: Lock, + label: 'Blocked', + colorClass: 'text-orange-500', + bgClass: 'bg-orange-500/20', + }, + { + icon: AlertCircle, + label: 'Error', + colorClass: 'text-[var(--status-error)]', + bgClass: 'bg-[var(--status-error)]/20', + }, +]; + +export function GraphLegend() { + return ( + +
+ {legendItems.map((item) => { + const Icon = item.icon; + return ( +
+
+ +
+ {item.label} +
+ ); + })} +
+
+ ); +} diff --git a/apps/ui/src/components/views/graph-view/components/index.ts b/apps/ui/src/components/views/graph-view/components/index.ts new file mode 100644 index 00000000..eb509a97 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/index.ts @@ -0,0 +1,4 @@ +export { TaskNode } from './task-node'; +export { DependencyEdge } from './dependency-edge'; +export { GraphControls } from './graph-controls'; +export { GraphLegend } from './graph-legend'; diff --git a/apps/ui/src/components/views/graph-view/components/task-node.tsx b/apps/ui/src/components/views/graph-view/components/task-node.tsx new file mode 100644 index 00000000..ebc8dcc2 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/components/task-node.tsx @@ -0,0 +1,248 @@ +import { memo } from 'react'; +import { Handle, Position } from '@xyflow/react'; +import type { NodeProps } from '@xyflow/react'; +import { cn } from '@/lib/utils'; +import { + Lock, + CheckCircle2, + Clock, + AlertCircle, + Play, + Pause, + Eye, + MoreHorizontal, + GitBranch, +} from 'lucide-react'; +import { TaskNodeData } from '../hooks/use-graph-nodes'; +import { Button } from '@/components/ui/button'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@/components/ui/tooltip'; + +type TaskNodeProps = NodeProps & { + data: TaskNodeData; +}; + +const statusConfig = { + backlog: { + icon: Clock, + label: 'Backlog', + colorClass: 'text-muted-foreground', + borderClass: 'border-border', + bgClass: 'bg-card', + }, + in_progress: { + icon: Play, + label: 'In Progress', + colorClass: 'text-[var(--status-in-progress)]', + borderClass: 'border-[var(--status-in-progress)]', + bgClass: 'bg-[var(--status-in-progress-bg)]', + }, + waiting_approval: { + icon: Pause, + label: 'Waiting Approval', + colorClass: 'text-[var(--status-waiting)]', + borderClass: 'border-[var(--status-waiting)]', + bgClass: 'bg-[var(--status-warning-bg)]', + }, + verified: { + icon: CheckCircle2, + label: 'Verified', + colorClass: 'text-[var(--status-success)]', + borderClass: 'border-[var(--status-success)]', + bgClass: 'bg-[var(--status-success-bg)]', + }, + completed: { + icon: CheckCircle2, + label: 'Completed', + colorClass: 'text-[var(--status-success)]', + borderClass: 'border-[var(--status-success)]/50', + bgClass: 'bg-[var(--status-success-bg)]/50', + }, +}; + +const priorityConfig = { + 1: { label: 'High', colorClass: 'bg-[var(--status-error)] text-white' }, + 2: { label: 'Medium', colorClass: 'bg-[var(--status-warning)] text-black' }, + 3: { label: 'Low', colorClass: 'bg-[var(--status-info)] text-white' }, +}; + +export const TaskNode = memo(function TaskNode({ + data, + selected, +}: TaskNodeProps) { + const config = statusConfig[data.status] || statusConfig.backlog; + const StatusIcon = config.icon; + const priorityConf = data.priority ? priorityConfig[data.priority as 1 | 2 | 3] : null; + + return ( + <> + {/* Target handle (left side - receives dependencies) */} + + +
+ {/* Header with status and actions */} +
+
+ + + {config.label} + +
+ +
+ {/* Priority badge */} + {priorityConf && ( + + {data.priority === 1 ? 'H' : data.priority === 2 ? 'M' : 'L'} + + )} + + {/* Blocked indicator */} + {data.isBlocked && !data.error && data.status === 'backlog' && ( + + + +
+ +
+
+ +

Blocked by {data.blockingDependencies.length} dependencies

+
+
+
+ )} + + {/* Error indicator */} + {data.error && ( + + + +
+ +
+
+ +

{data.error}

+
+
+
+ )} + + {/* Actions dropdown */} + + + + + + + + View Details + + {data.status === 'backlog' && !data.isBlocked && ( + + + Start Task + + )} + {data.isRunning && ( + + + Stop Task + + )} + + + + View Branch + + + +
+
+ + {/* Content */} +
+ {/* Category */} + + {data.category} + + + {/* Title */} +

+ {data.description} +

+ + {/* Progress indicator for in-progress tasks */} + {data.isRunning && ( +
+
+
+
+ Running... +
+ )} + + {/* Branch name if assigned */} + {data.branchName && ( +
+ + {data.branchName} +
+ )} +
+
+ + {/* Source handle (right side - provides to dependents) */} + + + ); +}); diff --git a/apps/ui/src/components/views/graph-view/graph-canvas.tsx b/apps/ui/src/components/views/graph-view/graph-canvas.tsx new file mode 100644 index 00000000..6251017d --- /dev/null +++ b/apps/ui/src/components/views/graph-view/graph-canvas.tsx @@ -0,0 +1,174 @@ +import { useCallback, useState, useEffect } from 'react'; +import { + ReactFlow, + Background, + BackgroundVariant, + MiniMap, + useNodesState, + useEdgesState, + ReactFlowProvider, + SelectionMode, + ConnectionMode, + Node, +} from '@xyflow/react'; +import '@xyflow/react/dist/style.css'; + +import { Feature } from '@/store/app-store'; +import { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components'; +import { useGraphNodes, useGraphLayout, type TaskNodeData } from './hooks'; +import { cn } from '@/lib/utils'; + +// Define custom node and edge types - using any to avoid React Flow's strict typing +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const nodeTypes: any = { + task: TaskNode, +}; + +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const edgeTypes: any = { + dependency: DependencyEdge, +}; + +interface GraphCanvasProps { + features: Feature[]; + runningAutoTasks: string[]; + onNodeClick?: (featureId: string) => void; + onNodeDoubleClick?: (featureId: string) => void; + backgroundStyle?: React.CSSProperties; + className?: string; +} + +function GraphCanvasInner({ + features, + runningAutoTasks, + onNodeClick, + onNodeDoubleClick, + backgroundStyle, + className, +}: GraphCanvasProps) { + const [isLocked, setIsLocked] = useState(false); + const [layoutDirection, setLayoutDirection] = useState<'LR' | 'TB'>('LR'); + + // Transform features to nodes and edges + const { nodes: initialNodes, edges: initialEdges } = useGraphNodes({ + features, + runningAutoTasks, + }); + + // Apply layout + const { layoutedNodes, layoutedEdges, runLayout } = useGraphLayout({ + nodes: initialNodes, + edges: initialEdges, + }); + + // React Flow state + const [nodes, setNodes, onNodesChange] = useNodesState(layoutedNodes); + const [edges, setEdges, onEdgesChange] = useEdgesState(layoutedEdges); + + // Update nodes/edges when features change + useEffect(() => { + setNodes(layoutedNodes); + setEdges(layoutedEdges); + }, [layoutedNodes, layoutedEdges, setNodes, setEdges]); + + // Handle layout direction change + const handleRunLayout = useCallback( + (direction: 'LR' | 'TB') => { + setLayoutDirection(direction); + runLayout(direction); + }, + [runLayout] + ); + + // Handle node click + const handleNodeClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + onNodeClick?.(node.id); + }, + [onNodeClick] + ); + + // Handle node double click + const handleNodeDoubleClick = useCallback( + (_event: React.MouseEvent, node: Node) => { + onNodeDoubleClick?.(node.id); + }, + [onNodeDoubleClick] + ); + + // MiniMap node color based on status + const minimapNodeColor = useCallback((node: Node) => { + const data = node.data as TaskNodeData | undefined; + const status = data?.status; + switch (status) { + case 'completed': + case 'verified': + return 'var(--status-success)'; + case 'in_progress': + return 'var(--status-in-progress)'; + case 'waiting_approval': + return 'var(--status-waiting)'; + default: + if (data?.isBlocked) return 'rgb(249, 115, 22)'; // orange-500 + if (data?.error) return 'var(--status-error)'; + return 'var(--muted-foreground)'; + } + }, []); + + return ( +
+ + + + + + setIsLocked(!isLocked)} + onRunLayout={handleRunLayout} + layoutDirection={layoutDirection} + /> + + + +
+ ); +} + +// Wrap with provider for hooks to work +export function GraphCanvas(props: GraphCanvasProps) { + return ( + + + + ); +} diff --git a/apps/ui/src/components/views/graph-view/graph-view.tsx b/apps/ui/src/components/views/graph-view/graph-view.tsx new file mode 100644 index 00000000..f745f37d --- /dev/null +++ b/apps/ui/src/components/views/graph-view/graph-view.tsx @@ -0,0 +1,89 @@ +import { useMemo, useCallback } from 'react'; +import { Feature, useAppStore } from '@/store/app-store'; +import { GraphCanvas } from './graph-canvas'; +import { useBoardBackground } from '../board-view/hooks'; + +interface GraphViewProps { + features: Feature[]; + runningAutoTasks: string[]; + currentWorktreePath: string | null; + currentWorktreeBranch: string | null; + projectPath: string | null; + onEditFeature: (feature: Feature) => void; + onViewOutput: (feature: Feature) => void; +} + +export function GraphView({ + features, + runningAutoTasks, + currentWorktreePath, + currentWorktreeBranch, + projectPath, + onEditFeature, + onViewOutput, +}: GraphViewProps) { + const { currentProject } = useAppStore(); + + // Use the same background hook as the board view + const { backgroundImageStyle } = useBoardBackground({ currentProject }); + + // Filter features by current worktree (same logic as board view) + const filteredFeatures = useMemo(() => { + const effectiveBranch = currentWorktreeBranch; + + return features.filter((f) => { + // Skip completed features (they're in archive) + if (f.status === 'completed') return false; + + const featureBranch = f.branchName; + + if (!featureBranch) { + // No branch assigned - show only on primary worktree + return currentWorktreePath === null; + } else if (effectiveBranch === null) { + // Viewing main but branch not initialized + return projectPath + ? useAppStore.getState().isPrimaryWorktreeBranch(projectPath, featureBranch) + : false; + } else { + // Match by branch name + return featureBranch === effectiveBranch; + } + }); + }, [features, currentWorktreePath, currentWorktreeBranch, projectPath]); + + // Handle node click - view details + const handleNodeClick = useCallback( + (featureId: string) => { + const feature = features.find((f) => f.id === featureId); + if (feature) { + onViewOutput(feature); + } + }, + [features, onViewOutput] + ); + + // Handle node double click - edit + const handleNodeDoubleClick = useCallback( + (featureId: string) => { + const feature = features.find((f) => f.id === featureId); + if (feature) { + onEditFeature(feature); + } + }, + [features, onEditFeature] + ); + + return ( +
+ +
+ ); +} diff --git a/apps/ui/src/components/views/graph-view/hooks/index.ts b/apps/ui/src/components/views/graph-view/hooks/index.ts new file mode 100644 index 00000000..48efda07 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/hooks/index.ts @@ -0,0 +1,2 @@ +export { useGraphNodes, type TaskNode, type DependencyEdge, type TaskNodeData } from './use-graph-nodes'; +export { useGraphLayout } from './use-graph-layout'; diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts new file mode 100644 index 00000000..b44b7bcf --- /dev/null +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-layout.ts @@ -0,0 +1,93 @@ +import { useCallback, useMemo } from 'react'; +import dagre from 'dagre'; +import { Node, Edge, useReactFlow } from '@xyflow/react'; +import { TaskNode, DependencyEdge } from './use-graph-nodes'; + +const NODE_WIDTH = 280; +const NODE_HEIGHT = 120; + +interface UseGraphLayoutProps { + nodes: TaskNode[]; + edges: DependencyEdge[]; +} + +/** + * Applies dagre layout to position nodes in a hierarchical DAG + * Dependencies flow left-to-right + */ +export function useGraphLayout({ nodes, edges }: UseGraphLayoutProps) { + const { fitView, setNodes } = useReactFlow(); + + const getLayoutedElements = useCallback( + ( + inputNodes: TaskNode[], + inputEdges: DependencyEdge[], + direction: 'LR' | 'TB' = 'LR' + ): { nodes: TaskNode[]; edges: DependencyEdge[] } => { + const dagreGraph = new dagre.graphlib.Graph(); + dagreGraph.setDefaultEdgeLabel(() => ({})); + + const isHorizontal = direction === 'LR'; + dagreGraph.setGraph({ + rankdir: direction, + nodesep: 50, + ranksep: 100, + marginx: 50, + marginy: 50, + }); + + inputNodes.forEach((node) => { + dagreGraph.setNode(node.id, { width: NODE_WIDTH, height: NODE_HEIGHT }); + }); + + inputEdges.forEach((edge) => { + dagreGraph.setEdge(edge.source, edge.target); + }); + + dagre.layout(dagreGraph); + + const layoutedNodes = inputNodes.map((node) => { + const nodeWithPosition = dagreGraph.node(node.id); + return { + ...node, + position: { + x: nodeWithPosition.x - NODE_WIDTH / 2, + y: nodeWithPosition.y - NODE_HEIGHT / 2, + }, + targetPosition: isHorizontal ? 'left' : 'top', + sourcePosition: isHorizontal ? 'right' : 'bottom', + } as TaskNode; + }); + + return { nodes: layoutedNodes, edges: inputEdges }; + }, + [] + ); + + // Initial layout + const layoutedElements = useMemo(() => { + if (nodes.length === 0) { + return { nodes: [], edges: [] }; + } + return getLayoutedElements(nodes, edges, 'LR'); + }, [nodes, edges, getLayoutedElements]); + + // Manual re-layout function + const runLayout = useCallback( + (direction: 'LR' | 'TB' = 'LR') => { + const { nodes: layoutedNodes } = getLayoutedElements(nodes, edges, direction); + setNodes(layoutedNodes); + // Fit view after layout with a small delay to allow DOM updates + setTimeout(() => { + fitView({ padding: 0.2, duration: 300 }); + }, 50); + }, + [nodes, edges, getLayoutedElements, setNodes, fitView] + ); + + return { + layoutedNodes: layoutedElements.nodes, + layoutedEdges: layoutedElements.edges, + runLayout, + }; +} diff --git a/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts new file mode 100644 index 00000000..cb1e038e --- /dev/null +++ b/apps/ui/src/components/views/graph-view/hooks/use-graph-nodes.ts @@ -0,0 +1,79 @@ +import { useMemo } from 'react'; +import { Node, Edge } from '@xyflow/react'; +import { Feature } from '@/store/app-store'; +import { getBlockingDependencies } from '@automaker/dependency-resolver'; + +export interface TaskNodeData extends Feature { + isBlocked: boolean; + isRunning: boolean; + blockingDependencies: string[]; +} + +export type TaskNode = Node; +export type DependencyEdge = Edge<{ sourceStatus: Feature['status']; targetStatus: Feature['status'] }>; + +interface UseGraphNodesProps { + features: Feature[]; + runningAutoTasks: string[]; +} + +/** + * Transforms features into React Flow nodes and edges + * Creates dependency edges based on feature.dependencies array + */ +export function useGraphNodes({ features, runningAutoTasks }: UseGraphNodesProps) { + const { nodes, edges } = useMemo(() => { + const nodeList: TaskNode[] = []; + const edgeList: DependencyEdge[] = []; + const featureMap = new Map(); + + // Create feature map for quick lookups + features.forEach((f) => featureMap.set(f.id, f)); + + // Create nodes + features.forEach((feature) => { + const isRunning = runningAutoTasks.includes(feature.id); + const blockingDeps = getBlockingDependencies(feature, features); + + const node: TaskNode = { + id: feature.id, + type: 'task', + position: { x: 0, y: 0 }, // Will be set by layout + data: { + ...feature, + isBlocked: blockingDeps.length > 0, + isRunning, + blockingDependencies: blockingDeps, + }, + }; + + nodeList.push(node); + + // Create edges for dependencies + if (feature.dependencies && feature.dependencies.length > 0) { + feature.dependencies.forEach((depId: string) => { + // Only create edge if the dependency exists in current view + if (featureMap.has(depId)) { + const sourceFeature = featureMap.get(depId)!; + const edge: DependencyEdge = { + id: `${depId}->${feature.id}`, + source: depId, + target: feature.id, + type: 'dependency', + animated: isRunning || runningAutoTasks.includes(depId), + data: { + sourceStatus: sourceFeature.status, + targetStatus: feature.status, + }, + }; + edgeList.push(edge); + } + }); + } + }); + + return { nodes: nodeList, edges: edgeList }; + }, [features, runningAutoTasks]); + + return { nodes, edges }; +} diff --git a/apps/ui/src/components/views/graph-view/index.ts b/apps/ui/src/components/views/graph-view/index.ts new file mode 100644 index 00000000..bcbfdcc0 --- /dev/null +++ b/apps/ui/src/components/views/graph-view/index.ts @@ -0,0 +1,4 @@ +export { GraphView } from './graph-view'; +export { GraphCanvas } from './graph-canvas'; +export { TaskNode, DependencyEdge, GraphControls, GraphLegend } from './components'; +export { useGraphNodes, useGraphLayout, type TaskNode as TaskNodeType, type DependencyEdge as DependencyEdgeType } from './hooks'; diff --git a/apps/ui/src/store/app-store.ts b/apps/ui/src/store/app-store.ts index cdfdd95a..da1c3ac3 100644 --- a/apps/ui/src/store/app-store.ts +++ b/apps/ui/src/store/app-store.ts @@ -51,6 +51,8 @@ export type ThemeMode = export type KanbanCardDetailLevel = 'minimal' | 'standard' | 'detailed'; +export type BoardViewMode = 'kanban' | 'graph'; + export interface ApiKeys { anthropic: string; google: string; @@ -450,6 +452,7 @@ export interface AppState { // Kanban Card Display Settings kanbanCardDetailLevel: KanbanCardDetailLevel; // Level of detail shown on kanban cards + boardViewMode: BoardViewMode; // Whether to show kanban or dependency graph view // Feature Default Settings defaultSkipTests: boolean; // Default value for skip tests when creating new features @@ -713,6 +716,7 @@ export interface AppActions { // Kanban Card Settings actions setKanbanCardDetailLevel: (level: KanbanCardDetailLevel) => void; + setBoardViewMode: (mode: BoardViewMode) => void; // Feature Default Settings actions setDefaultSkipTests: (skip: boolean) => void; @@ -916,6 +920,7 @@ const initialState: AppState = { autoModeActivityLog: [], maxConcurrency: 3, // Default to 3 concurrent agents kanbanCardDetailLevel: 'standard', // Default to standard detail level + boardViewMode: 'kanban', // Default to kanban view defaultSkipTests: true, // Default to manual verification (tests disabled) enableDependencyBlocking: true, // Default to enabled (show dependency blocking UI) useWorktrees: false, // Default to disabled (worktree feature is experimental) @@ -1466,6 +1471,7 @@ export const useAppStore = create()( // Kanban Card Settings actions setKanbanCardDetailLevel: (level) => set({ kanbanCardDetailLevel: level }), + setBoardViewMode: (mode) => set({ boardViewMode: mode }), // Feature Default Settings actions setDefaultSkipTests: (skip) => set({ defaultSkipTests: skip }), @@ -2673,6 +2679,7 @@ export const useAppStore = create()( sidebarOpen: state.sidebarOpen, chatHistoryOpen: state.chatHistoryOpen, kanbanCardDetailLevel: state.kanbanCardDetailLevel, + boardViewMode: state.boardViewMode, // Settings apiKeys: state.apiKeys, maxConcurrency: state.maxConcurrency, diff --git a/apps/ui/src/styles/global.css b/apps/ui/src/styles/global.css index 25e6af92..5fbfbf03 100644 --- a/apps/ui/src/styles/global.css +++ b/apps/ui/src/styles/global.css @@ -889,3 +889,162 @@ .xterm-viewport::-webkit-scrollbar-thumb:hover { background: var(--muted-foreground); } + +/* ======================================== + DEPENDENCY GRAPH STYLES + Theme-aware styling for React Flow graph + ======================================== */ + +/* React Flow base theme overrides */ +.graph-canvas { + --xy-background-color: transparent; + --xy-node-background-color: var(--card); + --xy-node-border-color: var(--border); + --xy-node-border-radius: 0.75rem; + --xy-edge-stroke-default: var(--border); + --xy-edge-stroke-selected: var(--brand-500); + --xy-minimap-background-color: var(--popover); + --xy-minimap-mask-background-color: rgba(0, 0, 0, 0.2); + --xy-controls-background-color: var(--popover); + --xy-controls-border-color: var(--border); +} + +/* MiniMap styling */ +.graph-canvas .react-flow__minimap { + background-color: var(--popover) !important; + border: 1px solid var(--border) !important; + border-radius: 0.5rem; +} + +.graph-canvas .react-flow__minimap-mask { + fill: var(--background); + fill-opacity: 0.8; +} + +/* Edge animations */ +@keyframes flow-dash { + to { + stroke-dashoffset: -20; + } +} + +@keyframes edge-glow { + 0%, 100% { + filter: drop-shadow(0 0 2px var(--status-in-progress)); + } + 50% { + filter: drop-shadow(0 0 6px var(--status-in-progress)); + } +} + +.graph-canvas .animated-edge path { + animation: flow-dash 0.5s linear infinite; +} + +.graph-canvas .edge-flowing path { + animation: + flow-dash 0.5s linear infinite, + edge-glow 2s ease-in-out infinite; +} + +/* Edge particle animation */ +.edge-particle { + pointer-events: none; +} + +/* Node animations */ +@keyframes pulse-subtle { + 0%, 100% { + box-shadow: 0 0 0 0 var(--status-in-progress); + } + 50% { + box-shadow: 0 0 15px 3px var(--status-in-progress); + } +} + +.animate-pulse-subtle { + animation: pulse-subtle 2s ease-in-out infinite; +} + +/* Progress bar indeterminate animation */ +@keyframes progress-indeterminate { + 0% { + transform: translateX(-100%); + width: 50%; + } + 50% { + transform: translateX(50%); + width: 30%; + } + 100% { + transform: translateX(200%); + width: 50%; + } +} + +.animate-progress-indeterminate { + animation: progress-indeterminate 1.5s ease-in-out infinite; +} + +/* Handle styling */ +.graph-canvas .react-flow__handle { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--border); + border: 2px solid var(--background); + transition: all 0.2s ease; +} + +.graph-canvas .react-flow__handle:hover { + background-color: var(--brand-500); + transform: scale(1.2); +} + +.graph-canvas .react-flow__handle-left { + left: -6px; +} + +.graph-canvas .react-flow__handle-right { + right: -6px; +} + +/* Selection styles */ +.graph-canvas .react-flow__node.selected { + outline: none; +} + +.graph-canvas .react-flow__edge.selected path { + stroke: var(--brand-500); + stroke-width: 3; +} + +/* Attribution removal (requires pro license) */ +.graph-canvas .react-flow__attribution { + display: none; +} + +/* Panel styling */ +.graph-canvas .react-flow__panel { + margin: 12px; +} + +/* Retro theme overrides */ +.retro .graph-canvas .react-flow__handle, +.retro .graph-canvas .react-flow__minimap { + border-radius: 0 !important; +} + +.retro .graph-canvas .react-flow__node { + border-radius: 0 !important; +} + +/* Reduce motion preference */ +@media (prefers-reduced-motion: reduce) { + .graph-canvas .animated-edge path, + .graph-canvas .edge-flowing path, + .animate-pulse-subtle, + .animate-progress-indeterminate { + animation: none; + } +} diff --git a/package-lock.json b/package-lock.json index fe06a572..b0130d99 100644 --- a/package-lock.json +++ b/package-lock.json @@ -99,9 +99,11 @@ "@xterm/addon-web-links": "^0.11.0", "@xterm/addon-webgl": "^0.18.0", "@xterm/xterm": "^5.5.0", + "@xyflow/react": "^12.10.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "dagre": "^0.8.5", "dotenv": "^17.2.3", "geist": "^1.5.1", "lucide-react": "^0.562.0", @@ -119,6 +121,7 @@ "@playwright/test": "^1.57.0", "@tailwindcss/vite": "^4.1.18", "@tanstack/router-plugin": "^1.141.7", + "@types/dagre": "^0.7.53", "@types/node": "^22", "@types/react": "^19.2.7", "@types/react-dom": "^19.2.3", @@ -1207,7 +1210,7 @@ }, "node_modules/@electron/node-gyp": { "version": "10.2.0-electron.1", - "resolved": "git+https://github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", + "resolved": "git+ssh://git@github.com/electron/node-gyp.git#06b29aafb7708acef8b3669835c8a7857ebc92d2", "integrity": "sha512-4MSBTT8y07YUDqf69/vSh80Hh791epYqGtWHO3zSKhYFwQg+gx9wi1PqbqP6YqC4WMsNxZ5l9oDmnWdK5pfCKQ==", "dev": true, "license": "MIT", @@ -5738,6 +5741,62 @@ "@types/node": "*" } }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-drag": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/@types/d3-drag/-/d3-drag-3.0.7.tgz", + "integrity": "sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-selection": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/d3-selection/-/d3-selection-3.0.11.tgz", + "integrity": "sha512-bhAXu23DJWsrI45xafYpkQ4NtcKMwWnAC/vKrd2l+nxMFuvOT3XMYTIj2opv8vq8AO5Yh7Qac/nSeP/3zjTK0w==", + "license": "MIT" + }, + "node_modules/@types/d3-transition": { + "version": "3.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-transition/-/d3-transition-3.0.9.tgz", + "integrity": "sha512-uZS5shfxzO3rGlu0cC3bjmMFKsXv+SmZZcgp0KD22ts4uGXp5EVYGzu/0YdwZeKmddhcAccYtREJKkPfXkZuCg==", + "license": "MIT", + "dependencies": { + "@types/d3-selection": "*" + } + }, + "node_modules/@types/d3-zoom": { + "version": "3.0.8", + "resolved": "https://registry.npmjs.org/@types/d3-zoom/-/d3-zoom-3.0.8.tgz", + "integrity": "sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==", + "license": "MIT", + "dependencies": { + "@types/d3-interpolate": "*", + "@types/d3-selection": "*" + } + }, + "node_modules/@types/dagre": { + "version": "0.7.53", + "resolved": "https://registry.npmjs.org/@types/dagre/-/dagre-0.7.53.tgz", + "integrity": "sha512-f4gkWqzPZvYmKhOsDnhq/R8mO4UMcKdxZo+i5SCkOU1wvGeHJeUXGIHeE9pnwGyPMDof1Vx5ZQo4nxpeg2TTVQ==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -6527,6 +6586,66 @@ "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", "license": "MIT" }, + "node_modules/@xyflow/react": { + "version": "12.10.0", + "resolved": "https://registry.npmjs.org/@xyflow/react/-/react-12.10.0.tgz", + "integrity": "sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==", + "license": "MIT", + "dependencies": { + "@xyflow/system": "0.0.74", + "classcat": "^5.0.3", + "zustand": "^4.4.0" + }, + "peerDependencies": { + "react": ">=17", + "react-dom": ">=17" + } + }, + "node_modules/@xyflow/react/node_modules/zustand": { + "version": "4.5.7", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.7.tgz", + "integrity": "sha512-CHOUy7mu3lbD6o6LJLfllpjkzhHXSBlX8B9+qPddUsIfeF5S/UZ5q0kmCsnRqT1UHFQZchNFDDzMbQsuesHWlw==", + "license": "MIT", + "dependencies": { + "use-sync-external-store": "^1.2.2" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } + }, + "node_modules/@xyflow/system": { + "version": "0.0.74", + "resolved": "https://registry.npmjs.org/@xyflow/system/-/system-0.0.74.tgz", + "integrity": "sha512-7v7B/PkiVrkdZzSbL+inGAo6tkR/WQHHG0/jhSvLQToCsfa8YubOGmBYd1s08tpKpihdHDZFwzQZeR69QSBb4Q==", + "license": "MIT", + "dependencies": { + "@types/d3-drag": "^3.0.7", + "@types/d3-interpolate": "^3.0.4", + "@types/d3-selection": "^3.0.10", + "@types/d3-transition": "^3.0.8", + "@types/d3-zoom": "^3.0.8", + "d3-drag": "^3.0.0", + "d3-interpolate": "^3.0.1", + "d3-selection": "^3.0.0", + "d3-zoom": "^3.0.0" + } + }, "node_modules/7zip-bin": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/7zip-bin/-/7zip-bin-5.2.0.tgz", @@ -7649,6 +7768,12 @@ "url": "https://polar.sh/cva" } }, + "node_modules/classcat": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/classcat/-/classcat-5.0.5.tgz", + "integrity": "sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==", + "license": "MIT" + }, "node_modules/clean-stack": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", @@ -8035,6 +8160,121 @@ "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", "license": "MIT" }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/dagre": { + "version": "0.8.5", + "resolved": "https://registry.npmjs.org/dagre/-/dagre-0.8.5.tgz", + "integrity": "sha512-/aTqmnRta7x7MCCpExk7HQL2O4owCT2h8NT//9I1OQ9vt29Pa0BzSAkR5lwFUcQ7491yVi/3CXU9jQ5o0Mn2Sw==", + "license": "MIT", + "dependencies": { + "graphlib": "^2.1.8", + "lodash": "^4.17.15" + } + }, "node_modules/debug": { "version": "4.4.3", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", @@ -9797,6 +10037,15 @@ "dev": true, "license": "ISC" }, + "node_modules/graphlib": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/graphlib/-/graphlib-2.1.8.tgz", + "integrity": "sha512-jcLLfkpoVGmH7/InMC/1hIvOPSUh38oJtGhvrOFGzioE1DZ+0YW16RgmOJhHiuWTvGiJQ9Z1Ik43JvkRPRvE+A==", + "license": "MIT", + "dependencies": { + "lodash": "^4.17.15" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -11077,7 +11326,6 @@ "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", - "dev": true, "license": "MIT" }, "node_modules/lodash.merge": {