feat(FN-3082): restructure dependency graph plugin with modular architectur
Implements a modular dependency graph feature for the Fusion dashboard plugin, replacing the monolithic `DependencyGraphView` component with a factored architecture: graph types and filtering (Step 1), a data hook (Step 2), auto-layout engine (Step 3), SVG edge rendering (Step 4), an interaction hoo Fusion-Task-Id: FN-3082
This commit is contained in:
@@ -2,34 +2,19 @@
|
||||
|
||||
Plugin-provided top-level **Graph** dashboard view for Fusion.
|
||||
|
||||
- Registers `dashboardViews: [{ viewId: "graph", placement: "more" }]`
|
||||
- Renders active task dependency graph for `triage`, `todo`, `in-progress`, `in-review`
|
||||
- Excludes `done` and `archived`
|
||||
- Persists drag positions in browser localStorage at:
|
||||
- `kb:${projectId}:dependency-graph-positions`
|
||||
## Rendering approach
|
||||
|
||||
The first version uses a lightweight custom SVG/HTML renderer (no React Flow dependency).
|
||||
- **Filtering**: includes `triage`, `todo`, `in-progress`, `in-review`; excludes `done`, `archived`
|
||||
- **Graph build**: edges are resolved only from `task.dependencies` as `source=dependent`, `target=dependency`
|
||||
- **Auto-layout**: Sugiyama-style layered layout (`computeAutoLayout`) groups nodes by dependency depth and spaces layers consistently
|
||||
- **Edge drawing**: SVG bezier curves from source bottom-center to target top-center, with arrowheads showing dependent → dependency direction
|
||||
- **Interaction**: pan, wheel zoom, pinch zoom, zoom-in/out controls, reset, and fit-to-screen
|
||||
- **Fit-to-screen**: computes node bounding box with layout node dimensions and applies zoom/pan so the graph fits in viewport with padding
|
||||
|
||||
## Mobile Support
|
||||
## Controls
|
||||
|
||||
The dependency graph view is fully usable on mobile devices:
|
||||
- **Fit to screen** (`Maximize`)
|
||||
- **Zoom in** (`ZoomIn`)
|
||||
- **Zoom out** (`ZoomOut`)
|
||||
|
||||
- **Pinch-to-zoom** — Two-finger pinch gestures scale the graph proportionally, clamped to `[0.4×, 2×]`
|
||||
- **Mouse wheel zoom** — Desktop users can scroll-wheel to zoom toward the pointer position
|
||||
- **Auto-fit on mobile** — On initial mobile load, the graph automatically fits all nodes into the viewport
|
||||
- **Touch-friendly controls** — Zoom In, Zoom Out, and Fit buttons have 44px minimum touch targets on mobile
|
||||
- **Sticky control bar** — Controls remain accessible at the top of the viewport while panning the graph on mobile
|
||||
- **Touch-action isolation** — `touch-action: none` on the canvas prevents browser gesture interference with custom pan/zoom
|
||||
- **Empty state** — When no active tasks exist, a centered message guides the user instead of showing an empty canvas
|
||||
|
||||
### Supported Interactions
|
||||
|
||||
| Input | Action |
|
||||
|-------|--------|
|
||||
| Single pointer drag on canvas | Pan the graph |
|
||||
| Single pointer drag on node | Move the node |
|
||||
| Single pointer click on node | Open task detail |
|
||||
| Two-finger pinch | Zoom in/out |
|
||||
| Mouse wheel | Zoom toward pointer |
|
||||
| Zoom In / Zoom Out buttons | Step zoom ±0.1× |
|
||||
| Fit button | Auto-fit all nodes to viewport |
|
||||
All controls are rendered as floating `.btn-icon` actions in the bottom-right corner, with mobile-friendly sizing in the `@media (max-width: 768px)` override.
|
||||
|
||||
@@ -10,8 +10,8 @@
|
||||
"import": "./src/index.ts"
|
||||
},
|
||||
"./dashboard-view": {
|
||||
"types": "./src/DependencyGraphView.tsx",
|
||||
"import": "./src/DependencyGraphView.tsx"
|
||||
"types": "./src/DependencyGraph.tsx",
|
||||
"import": "./src/DependencyGraph.tsx"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
@@ -20,7 +20,9 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@fusion/core": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*"
|
||||
"@fusion/dashboard": "workspace:*",
|
||||
"@fusion/plugin-sdk": "workspace:*",
|
||||
"lucide-react": "^0.542.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@testing-library/react": "^16.3.2",
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
.dependency-graph {
|
||||
position: relative;
|
||||
height: 100%;
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.dependency-graph__viewport {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
height: 100%;
|
||||
min-height: calc(var(--space-2xl) * 10);
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dependency-graph__canvas {
|
||||
position: relative;
|
||||
transform-origin: top left;
|
||||
}
|
||||
|
||||
.dependency-graph-edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.dependency-graph__node {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.dependency-graph__node .card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dependency-graph__empty {
|
||||
color: var(--text-muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dependency-graph__toolbar {
|
||||
position: absolute;
|
||||
right: var(--space-md);
|
||||
bottom: var(--space-md);
|
||||
display: flex;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dependency-graph {
|
||||
padding: var(--space-sm);
|
||||
}
|
||||
|
||||
.dependency-graph__toolbar {
|
||||
right: var(--space-sm);
|
||||
bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.dependency-graph__toolbar .btn-icon {
|
||||
min-width: calc(var(--space-xs) * 11);
|
||||
min-height: calc(var(--space-xs) * 11);
|
||||
}
|
||||
}
|
||||
118
plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx
Normal file
118
plugins/fusion-plugin-dependency-graph/src/DependencyGraph.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
import { Maximize, ZoomIn, ZoomOut } from "lucide-react";
|
||||
import { useEffect, useMemo, useRef } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { TaskCard } from "@fusion/dashboard/app/components/TaskCard";
|
||||
import { GraphEdges } from "./edges";
|
||||
import { filterGraphTasks } from "./filters";
|
||||
import { computeAutoLayout } from "./layout";
|
||||
import { useGraphData } from "./useGraphData";
|
||||
import { useGraphInteraction } from "./useGraphInteraction";
|
||||
import "./DependencyGraph.css";
|
||||
|
||||
const NODE_WIDTH = 280;
|
||||
const NODE_HEIGHT = 100;
|
||||
|
||||
export interface DependencyGraphProps {
|
||||
tasks: Task[];
|
||||
projectId?: string;
|
||||
onOpenTaskDetail: (taskId: string) => void;
|
||||
}
|
||||
|
||||
export function DependencyGraph({ tasks, projectId, onOpenTaskDetail }: DependencyGraphProps) {
|
||||
const viewportRef = useRef<HTMLDivElement | null>(null);
|
||||
const filteredTasks = useMemo(() => filterGraphTasks(tasks), [tasks]);
|
||||
const graphData = useGraphData(filteredTasks);
|
||||
const positions = useMemo(
|
||||
() => computeAutoLayout(graphData, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT, horizontalGap: 40, verticalGap: 80 }),
|
||||
[graphData],
|
||||
);
|
||||
|
||||
const {
|
||||
transform,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
fitToGraph,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onWheelZoom,
|
||||
} = useGraphInteraction();
|
||||
|
||||
useEffect(() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
||||
}, [fitToGraph, positions]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
const values = Array.from(positions.values());
|
||||
if (values.length === 0) return { width: 0, height: 0 };
|
||||
const maxX = Math.max(...values.map((pos) => pos.x + NODE_WIDTH));
|
||||
const maxY = Math.max(...values.map((pos) => pos.y + NODE_HEIGHT));
|
||||
return { width: maxX, height: maxY };
|
||||
}, [positions]);
|
||||
|
||||
return (
|
||||
<section className="dependency-graph" data-testid="dependency-graph">
|
||||
<div
|
||||
ref={viewportRef}
|
||||
className="dependency-graph__viewport"
|
||||
onPointerDown={(event) => onPointerDown(event.pointerId, { x: event.clientX, y: event.clientY })}
|
||||
onPointerMove={(event) => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
onPointerMove(event.pointerId, { x: event.clientX, y: event.clientY }, viewport.clientWidth, viewport.clientHeight);
|
||||
}}
|
||||
onPointerUp={(event) => onPointerUp(event.pointerId)}
|
||||
onPointerCancel={(event) => onPointerUp(event.pointerId)}
|
||||
onWheel={(event) => {
|
||||
event.preventDefault();
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
const rect = viewport.getBoundingClientRect();
|
||||
onWheelZoom(event.deltaY, { x: event.clientX - rect.left, y: event.clientY - rect.top }, viewport.clientWidth, viewport.clientHeight);
|
||||
}}
|
||||
>
|
||||
{filteredTasks.length === 0 ? (
|
||||
<div className="dependency-graph__empty">No active tasks to display in graph view.</div>
|
||||
) : (
|
||||
<div className="dependency-graph__canvas" style={{ transform, width: `${bounds.width}px`, height: `${bounds.height}px` }}>
|
||||
<GraphEdges edges={graphData.edges} positions={positions} nodeWidth={NODE_WIDTH} nodeHeight={NODE_HEIGHT} />
|
||||
{graphData.nodes.map((node) => {
|
||||
const position = positions.get(node.task.id);
|
||||
if (!position) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={node.task.id}
|
||||
className="dependency-graph__node"
|
||||
style={{ width: `${NODE_WIDTH}px`, minHeight: `${NODE_HEIGHT}px`, left: `${position.x}px`, top: `${position.y}px` }}
|
||||
>
|
||||
<TaskCard
|
||||
task={node.task}
|
||||
projectId={projectId}
|
||||
onOpenDetail={() => onOpenTaskDetail(node.task.id)}
|
||||
addToast={() => {}}
|
||||
disableDrag={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="dependency-graph__toolbar">
|
||||
<button className="btn btn-icon" aria-label="Fit to screen" onClick={() => {
|
||||
const viewport = viewportRef.current;
|
||||
if (!viewport) return;
|
||||
fitToGraph(positions, viewport.clientWidth, viewport.clientHeight, { nodeWidth: NODE_WIDTH, nodeHeight: NODE_HEIGHT });
|
||||
}}>
|
||||
<Maximize size={16} />
|
||||
</button>
|
||||
<button className="btn btn-icon" aria-label="Zoom in" onClick={zoomIn}><ZoomIn size={16} /></button>
|
||||
<button className="btn btn-icon" aria-label="Zoom out" onClick={zoomOut}><ZoomOut size={16} /></button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
.dependency-graph-view {
|
||||
--dependency-graph-canvas-min-height: calc(var(--space-2xl) * 10);
|
||||
--dependency-graph-canvas-min-height-mobile: calc(var(--space-2xl) * 8);
|
||||
--dependency-graph-edge-width: var(--btn-border-width);
|
||||
--dependency-graph-node-max-width-mobile: calc(var(--space-2xl) * 10);
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.dependency-graph-controls {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.dependency-graph-canvas {
|
||||
overflow: auto;
|
||||
border: var(--btn-border-width) solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--surface);
|
||||
min-height: var(--dependency-graph-canvas-min-height);
|
||||
cursor: grab;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.dependency-graph-canvas:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dependency-graph-scene {
|
||||
position: relative;
|
||||
transition: transform var(--transition-fast);
|
||||
}
|
||||
|
||||
.dependency-graph-edges {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dependency-graph-edge {
|
||||
stroke: var(--border);
|
||||
stroke-width: var(--dependency-graph-edge-width);
|
||||
transition: stroke var(--transition-fast), opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.dependency-graph-edge.is-related {
|
||||
stroke: var(--todo);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-graph-edge.is-dimmed {
|
||||
stroke: var(--border);
|
||||
opacity: 0.35;
|
||||
}
|
||||
|
||||
.dependency-graph-node {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
cursor: grab;
|
||||
transition: opacity var(--transition-fast), filter var(--transition-fast), box-shadow var(--transition-fast);
|
||||
}
|
||||
|
||||
.dependency-graph-node:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.dependency-graph-node .card {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.dependency-graph-node.is-selected .card {
|
||||
box-shadow: var(--focus-ring-strong);
|
||||
border-color: var(--todo);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-graph-node.is-related:not(.is-selected) .card {
|
||||
border-color: var(--in-progress);
|
||||
box-shadow: var(--shadow-sm);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.dependency-graph-node.is-dimmed {
|
||||
opacity: 0.5;
|
||||
filter: saturate(0.8);
|
||||
}
|
||||
|
||||
.dependency-graph-node.is-dimmed .card {
|
||||
border-color: var(--border);
|
||||
}
|
||||
|
||||
.dependency-graph-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 100%;
|
||||
min-height: var(--dependency-graph-canvas-min-height);
|
||||
padding: var(--space-xl);
|
||||
color: var(--text-muted);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.dependency-graph-view {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.dependency-graph-controls {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
background: var(--bg);
|
||||
padding: var(--space-sm) 0;
|
||||
}
|
||||
|
||||
.dependency-graph-controls .btn {
|
||||
min-height: calc(var(--space-xs) * 11);
|
||||
min-width: calc(var(--space-xs) * 11);
|
||||
}
|
||||
|
||||
.dependency-graph-canvas {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
min-height: var(--dependency-graph-canvas-min-height-mobile);
|
||||
}
|
||||
|
||||
.dependency-graph-node {
|
||||
width: min(100%, var(--dependency-graph-node-max-width-mobile)) !important;
|
||||
}
|
||||
|
||||
.dependency-graph-empty {
|
||||
min-height: var(--dependency-graph-canvas-min-height-mobile);
|
||||
}
|
||||
}
|
||||
@@ -1,428 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import type { PointerEvent as ReactPointerEvent, ReactNode, WheelEvent as ReactWheelEvent } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { loadPositions, savePositions } from "./storage";
|
||||
import "./DependencyGraphView.css";
|
||||
|
||||
const ACTIVE_COLUMNS = new Set(["triage", "todo", "in-progress", "in-review"]);
|
||||
const NODE_WIDTH_REM = 18;
|
||||
const NODE_HEIGHT_REM = 9;
|
||||
const GRID_GAP_X_REM = 3;
|
||||
const GRID_GAP_Y_REM = 4;
|
||||
const DRAG_THRESHOLD_REM = 0.5;
|
||||
const SCENE_PADDING_REM = 2;
|
||||
const FIT_PADDING_REM = 2;
|
||||
const MIN_SCALE = 0.4;
|
||||
const MAX_SCALE = 2;
|
||||
const WHEEL_ZOOM_FACTOR = 0.002;
|
||||
const MOBILE_BREAKPOINT = 768;
|
||||
|
||||
export interface DependencyGraphHostContext {
|
||||
projectId?: string;
|
||||
tasks: Task[];
|
||||
openTaskDetail: (task: Task) => void;
|
||||
renderTaskCard: (task: Task) => ReactNode;
|
||||
}
|
||||
|
||||
export interface PluginDashboardViewComponentProps {
|
||||
context: DependencyGraphHostContext;
|
||||
}
|
||||
|
||||
type Position = { x: number; y: number };
|
||||
|
||||
function getDistance(a: Position, b: Position): number {
|
||||
return Math.hypot(a.x - b.x, a.y - b.y);
|
||||
}
|
||||
|
||||
function isMobileViewport(): boolean {
|
||||
if (typeof window === "undefined") return false;
|
||||
return window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT}px)`).matches;
|
||||
}
|
||||
|
||||
export function DependencyGraphView({ context }: PluginDashboardViewComponentProps) {
|
||||
const [scale, setScale] = useState(1);
|
||||
const [pan, setPan] = useState<Position>({ x: 0, y: 0 });
|
||||
const [nodeOverrides, setNodeOverrides] = useState<Record<string, Position>>({});
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [hoveredTaskId, setHoveredTaskId] = useState<string | null>(null);
|
||||
const persisted = useMemo(() => loadPositions(context.projectId), [context.projectId]);
|
||||
const canvasRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// Multi-pointer tracking (no setPointerCapture — it breaks two-finger gestures)
|
||||
const pointersRef = useRef<Map<number, Position>>(new Map());
|
||||
const interactionRef = useRef<
|
||||
| { kind: "node"; taskId: string; startPointer: Position; startNode: Position; moved: boolean }
|
||||
| { kind: "pan"; startPointer: Position; startPan: Position; moved: boolean }
|
||||
| null
|
||||
>(null);
|
||||
const pinchRef = useRef<{ startDistance: number; startScale: number } | null>(null);
|
||||
const autoFitDoneRef = useRef(false);
|
||||
|
||||
const tasks = useMemo(
|
||||
() => context.tasks.filter((task) => ACTIVE_COLUMNS.has(task.column)),
|
||||
[context.tasks],
|
||||
);
|
||||
|
||||
const positioned = useMemo(() => {
|
||||
return tasks.map((task, index) => {
|
||||
const saved = nodeOverrides[task.id] ?? persisted[task.id];
|
||||
return {
|
||||
task,
|
||||
x: saved?.x ?? (index % 4) * (NODE_WIDTH_REM + GRID_GAP_X_REM),
|
||||
y: saved?.y ?? Math.floor(index / 4) * (NODE_HEIGHT_REM + GRID_GAP_Y_REM),
|
||||
};
|
||||
});
|
||||
}, [nodeOverrides, persisted, tasks]);
|
||||
|
||||
const map = useMemo(() => new Map(positioned.map((node) => [node.task.id, node])), [positioned]);
|
||||
|
||||
const edges = useMemo(() => {
|
||||
const lines: Array<{ from: string; to: string; x1: number; y1: number; x2: number; y2: number }> = [];
|
||||
positioned.forEach((node) => {
|
||||
(node.task.dependencies ?? []).forEach((dependencyId) => {
|
||||
const dependency = map.get(dependencyId);
|
||||
if (!dependency) return;
|
||||
lines.push({
|
||||
from: dependencyId,
|
||||
to: node.task.id,
|
||||
x1: dependency.x + NODE_WIDTH_REM,
|
||||
y1: dependency.y + NODE_HEIGHT_REM / 2,
|
||||
x2: node.x,
|
||||
y2: node.y + NODE_HEIGHT_REM / 2,
|
||||
});
|
||||
});
|
||||
});
|
||||
return lines;
|
||||
}, [map, positioned]);
|
||||
|
||||
const bounds = useMemo(() => {
|
||||
if (positioned.length === 0) {
|
||||
return { minX: 0, minY: 0, width: NODE_WIDTH_REM * 2, height: NODE_HEIGHT_REM * 2 };
|
||||
}
|
||||
|
||||
const minX = Math.min(...positioned.map((node) => node.x)) - SCENE_PADDING_REM;
|
||||
const minY = Math.min(...positioned.map((node) => node.y)) - SCENE_PADDING_REM;
|
||||
const maxX = Math.max(...positioned.map((node) => node.x + NODE_WIDTH_REM)) + SCENE_PADDING_REM;
|
||||
const maxY = Math.max(...positioned.map((node) => node.y + NODE_HEIGHT_REM)) + SCENE_PADDING_REM;
|
||||
|
||||
return {
|
||||
minX,
|
||||
minY,
|
||||
width: Math.max(NODE_WIDTH_REM * 2, maxX - minX),
|
||||
height: Math.max(NODE_HEIGHT_REM * 2, maxY - minY),
|
||||
};
|
||||
}, [positioned]);
|
||||
|
||||
const positionedForRender = useMemo(
|
||||
() =>
|
||||
positioned.map((node) => ({
|
||||
...node,
|
||||
renderX: node.x - bounds.minX,
|
||||
renderY: node.y - bounds.minY,
|
||||
})),
|
||||
[bounds.minX, bounds.minY, positioned],
|
||||
);
|
||||
|
||||
const edgesForRender = useMemo(
|
||||
() =>
|
||||
edges.map((edge) => ({
|
||||
...edge,
|
||||
renderX1: edge.x1 - bounds.minX,
|
||||
renderY1: edge.y1 - bounds.minY,
|
||||
renderX2: edge.x2 - bounds.minX,
|
||||
renderY2: edge.y2 - bounds.minY,
|
||||
})),
|
||||
[bounds.minX, bounds.minY, edges],
|
||||
);
|
||||
|
||||
const dependencyGraph = useMemo(() => {
|
||||
const downstream = new Map<string, Set<string>>();
|
||||
const upstream = new Map<string, Set<string>>();
|
||||
|
||||
edges.forEach((edge) => {
|
||||
downstream.set(edge.from, (downstream.get(edge.from) ?? new Set<string>()).add(edge.to));
|
||||
upstream.set(edge.to, (upstream.get(edge.to) ?? new Set<string>()).add(edge.from));
|
||||
});
|
||||
|
||||
return { downstream, upstream };
|
||||
}, [edges]);
|
||||
|
||||
const focusTaskId = hoveredTaskId ?? selectedTaskId;
|
||||
|
||||
const relatedTaskIds = useMemo(() => {
|
||||
if (!focusTaskId) return null;
|
||||
|
||||
const related = new Set<string>([focusTaskId]);
|
||||
const walk = (seed: string, map: Map<string, Set<string>>) => {
|
||||
const queue = [seed];
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
if (!current) continue;
|
||||
(map.get(current) ?? new Set<string>()).forEach((next) => {
|
||||
if (related.has(next)) return;
|
||||
related.add(next);
|
||||
queue.push(next);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
walk(focusTaskId, dependencyGraph.downstream);
|
||||
walk(focusTaskId, dependencyGraph.upstream);
|
||||
|
||||
return related;
|
||||
}, [dependencyGraph.downstream, dependencyGraph.upstream, focusTaskId]);
|
||||
|
||||
const fitToGraph = useCallback(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const widthPx = bounds.width * rootFontSize;
|
||||
const heightPx = bounds.height * rootFontSize;
|
||||
const paddingPx = FIT_PADDING_REM * rootFontSize;
|
||||
const availableWidth = Math.max(1, canvas.clientWidth - paddingPx * 2);
|
||||
const availableHeight = Math.max(1, canvas.clientHeight - paddingPx * 2);
|
||||
|
||||
const nextScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, Math.min(availableWidth / widthPx, availableHeight / heightPx)));
|
||||
const centeredPanX = (canvas.clientWidth - widthPx * nextScale) / (2 * rootFontSize * nextScale);
|
||||
const centeredPanY = (canvas.clientHeight - heightPx * nextScale) / (2 * rootFontSize * nextScale);
|
||||
|
||||
setScale(nextScale);
|
||||
setPan({ x: centeredPanX, y: centeredPanY });
|
||||
}, [bounds.width, bounds.height]);
|
||||
|
||||
// Auto-fit on initial mobile load
|
||||
useEffect(() => {
|
||||
if (autoFitDoneRef.current) return;
|
||||
if (!isMobileViewport()) return;
|
||||
if (positioned.length === 0) return;
|
||||
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
// Ensure the canvas has non-zero dimensions before fitting
|
||||
if (canvas.clientWidth === 0 || canvas.clientHeight === 0) return;
|
||||
|
||||
autoFitDoneRef.current = true;
|
||||
// Use rAF to ensure layout is settled
|
||||
requestAnimationFrame(() => {
|
||||
fitToGraph();
|
||||
});
|
||||
}, [fitToGraph, positioned.length]);
|
||||
|
||||
const persistPosition = (taskId: string, next: Position) => {
|
||||
setNodeOverrides((current) => ({ ...current, [taskId]: next }));
|
||||
savePositions(context.projectId, { ...persisted, ...nodeOverrides, [taskId]: next });
|
||||
};
|
||||
|
||||
const handlePointerDownOnNode = (taskId: string, event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
setSelectedTaskId((current) => (current === taskId ? null : taskId));
|
||||
if (event.button !== 0) return;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const hit = map.get(taskId);
|
||||
if (!hit) return;
|
||||
|
||||
// Track this pointer in the global map
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
|
||||
interactionRef.current = {
|
||||
kind: "node",
|
||||
taskId,
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startNode: { x: hit.x, y: hit.y },
|
||||
moved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handleCanvasPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
if (event.button !== 0) return;
|
||||
|
||||
// Track this pointer
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
|
||||
// If we already have another pointer, this is the start of a pinch gesture
|
||||
if (pointersRef.current.size === 2) {
|
||||
// Cancel any ongoing pan interaction
|
||||
interactionRef.current = null;
|
||||
const [p1, p2] = Array.from(pointersRef.current.values());
|
||||
const distance = getDistance(p1, p2);
|
||||
pinchRef.current = { startDistance: distance, startScale: scale };
|
||||
return;
|
||||
}
|
||||
|
||||
interactionRef.current = {
|
||||
kind: "pan",
|
||||
startPointer: { x: event.clientX, y: event.clientY },
|
||||
startPan: pan,
|
||||
moved: false,
|
||||
};
|
||||
};
|
||||
|
||||
const handlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
// Update tracked pointer position
|
||||
if (pointersRef.current.has(event.pointerId)) {
|
||||
pointersRef.current.set(event.pointerId, { x: event.clientX, y: event.clientY });
|
||||
}
|
||||
|
||||
// Handle pinch gesture when two pointers are active
|
||||
if (pointersRef.current.size >= 2 && pinchRef.current) {
|
||||
const [p1, p2] = Array.from(pointersRef.current.values());
|
||||
const currentDistance = getDistance(p1, p2);
|
||||
const scaleFactor = currentDistance / pinchRef.current.startDistance;
|
||||
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, pinchRef.current.startScale * scaleFactor));
|
||||
setScale(newScale);
|
||||
return;
|
||||
}
|
||||
|
||||
const current = interactionRef.current;
|
||||
if (!current) return;
|
||||
|
||||
const delta = {
|
||||
x: (event.clientX - current.startPointer.x) / 16,
|
||||
y: (event.clientY - current.startPointer.y) / 16,
|
||||
};
|
||||
|
||||
if (current.kind === "node") {
|
||||
const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
|
||||
if (moved && !current.moved) current.moved = true;
|
||||
if (!current.moved) return;
|
||||
setNodeOverrides((existing) => ({
|
||||
...existing,
|
||||
[current.taskId]: { x: current.startNode.x + delta.x / scale, y: current.startNode.y + delta.y / scale },
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
const moved = getDistance({ x: 0, y: 0 }, delta) > DRAG_THRESHOLD_REM;
|
||||
if (moved && !current.moved) current.moved = true;
|
||||
if (!current.moved) return;
|
||||
setPan({ x: current.startPan.x + delta.x, y: current.startPan.y + delta.y });
|
||||
};
|
||||
|
||||
const handlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
|
||||
// If we had a pinch and one finger remains, end pinch mode
|
||||
if (pinchRef.current) {
|
||||
if (pointersRef.current.size < 2) {
|
||||
pinchRef.current = null;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const current = interactionRef.current;
|
||||
interactionRef.current = null;
|
||||
if (!current) return;
|
||||
|
||||
if (current.kind === "node") {
|
||||
const hit = map.get(current.taskId);
|
||||
if (!hit) return;
|
||||
|
||||
if (!current.moved) {
|
||||
context.openTaskDetail(hit.task);
|
||||
return;
|
||||
}
|
||||
|
||||
persistPosition(current.taskId, { x: hit.x, y: hit.y });
|
||||
}
|
||||
};
|
||||
|
||||
const handlePointerCancel = (event: ReactPointerEvent<HTMLDivElement>) => {
|
||||
pointersRef.current.delete(event.pointerId);
|
||||
pinchRef.current = null;
|
||||
interactionRef.current = null;
|
||||
};
|
||||
|
||||
const handleWheel = (event: ReactWheelEvent<HTMLDivElement>) => {
|
||||
event.preventDefault();
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
|
||||
const rootFontSize = Number.parseFloat(globalThis.getComputedStyle(document.documentElement).fontSize) || 16;
|
||||
const delta = -event.deltaY * WHEEL_ZOOM_FACTOR;
|
||||
const newScale = Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale * (1 + delta)));
|
||||
|
||||
// Zoom toward the pointer position
|
||||
const rect = canvas.getBoundingClientRect();
|
||||
const pointerX = event.clientX - rect.left;
|
||||
const pointerY = event.clientY - rect.top;
|
||||
|
||||
// How much the point under the cursor should shift in rem
|
||||
const scaleRatio = newScale / scale;
|
||||
const panOffsetX = (pointerX / rootFontSize) * (1 - scaleRatio) / scale;
|
||||
const panOffsetY = (pointerY / rootFontSize) * (1 - scaleRatio) / scale;
|
||||
|
||||
setScale(newScale);
|
||||
setPan((prev) => ({
|
||||
x: prev.x + panOffsetX * newScale / scale,
|
||||
y: prev.y + panOffsetY * newScale / scale,
|
||||
}));
|
||||
};
|
||||
|
||||
return (
|
||||
<section className="dependency-graph-view">
|
||||
<div className="dependency-graph-controls">
|
||||
<button className="btn btn-sm" onClick={() => setScale((value) => Math.min(value + 0.1, MAX_SCALE))}>Zoom In</button>
|
||||
<button className="btn btn-sm" onClick={() => setScale((value) => Math.max(value - 0.1, MIN_SCALE))}>Zoom Out</button>
|
||||
<button className="btn btn-sm" onClick={fitToGraph}>Fit</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="dependency-graph-canvas"
|
||||
ref={canvasRef}
|
||||
onPointerDown={handleCanvasPointerDown}
|
||||
onPointerMove={handlePointerMove}
|
||||
onPointerUp={handlePointerUp}
|
||||
onPointerCancel={handlePointerCancel}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
{tasks.length === 0 ? (
|
||||
<div className="dependency-graph-empty">
|
||||
<p>No tasks to display. Tasks in Triage, Todo, In Progress, or In Review columns will appear here.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="dependency-graph-scene"
|
||||
style={{
|
||||
width: `${bounds.width}rem`,
|
||||
height: `${bounds.height}rem`,
|
||||
transform: `translate(${pan.x}rem, ${pan.y}rem) scale(${scale})`,
|
||||
transformOrigin: "top left",
|
||||
}}
|
||||
>
|
||||
<svg className="dependency-graph-edges" viewBox={`0 0 ${bounds.width} ${bounds.height}`}>
|
||||
{edgesForRender.map((edge) => (
|
||||
<line
|
||||
key={`${edge.from}-${edge.to}`}
|
||||
x1={edge.renderX1}
|
||||
y1={edge.renderY1}
|
||||
x2={edge.renderX2}
|
||||
y2={edge.renderY2}
|
||||
className={`dependency-graph-edge${relatedTaskIds ? relatedTaskIds.has(edge.from) && relatedTaskIds.has(edge.to) ? " is-related" : " is-dimmed" : ""}`}
|
||||
/>
|
||||
))}
|
||||
</svg>
|
||||
|
||||
{positionedForRender.map((node) => (
|
||||
<div
|
||||
key={node.task.id}
|
||||
className={`dependency-graph-node${selectedTaskId === node.task.id ? " is-selected" : ""}${relatedTaskIds ? relatedTaskIds.has(node.task.id) ? " is-related" : " is-dimmed" : ""}`}
|
||||
style={{
|
||||
width: `${NODE_WIDTH_REM}rem`,
|
||||
minHeight: `${NODE_HEIGHT_REM}rem`,
|
||||
transform: `translate(${node.renderX}rem, ${node.renderY}rem)`,
|
||||
}}
|
||||
onPointerDown={(event) => handlePointerDownOnNode(node.task.id, event)}
|
||||
onPointerEnter={() => setHoveredTaskId(node.task.id)}
|
||||
onPointerLeave={() => setHoveredTaskId((current) => (current === node.task.id ? null : current))}
|
||||
>
|
||||
{context.renderTaskCard(node.task)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { DependencyGraph } from "../DependencyGraph";
|
||||
|
||||
const fitToGraph = vi.fn();
|
||||
|
||||
vi.mock("@fusion/dashboard/app/components/TaskCard", () => ({
|
||||
TaskCard: ({ task, onOpenDetail }: { task: Task; onOpenDetail: () => void }) => (
|
||||
<button data-testid={`task-${task.id}`} onClick={onOpenDetail}>{task.id}</button>
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("../useGraphInteraction", () => ({
|
||||
useGraphInteraction: () => ({
|
||||
transform: "translate(0px, 0px) scale(1)",
|
||||
zoomIn: vi.fn(),
|
||||
zoomOut: vi.fn(),
|
||||
fitToGraph,
|
||||
onPointerDown: vi.fn(),
|
||||
onPointerMove: vi.fn(),
|
||||
onPointerUp: vi.fn(),
|
||||
onWheelZoom: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
|
||||
return { id, description: id, column, dependencies, steps: [], currentStep: 0, log: [] } as Task;
|
||||
}
|
||||
|
||||
describe("DependencyGraph", () => {
|
||||
beforeEach(() => {
|
||||
fitToGraph.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it("renders empty state for empty list", () => {
|
||||
render(<DependencyGraph tasks={[]} onOpenTaskDetail={vi.fn()} />);
|
||||
expect(screen.getByText(/No active tasks/i)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("renders positioned nodes and edges for included tasks", () => {
|
||||
render(<DependencyGraph tasks={[
|
||||
createTask("A", "todo"),
|
||||
createTask("B", "in-progress", ["A"]),
|
||||
]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
||||
expect(screen.getByTestId("task-B")).toBeTruthy();
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("excludes done and archived nodes", () => {
|
||||
render(<DependencyGraph tasks={[
|
||||
createTask("A", "todo"),
|
||||
createTask("B", "done"),
|
||||
createTask("C", "archived"),
|
||||
]} onOpenTaskDetail={vi.fn()} />);
|
||||
|
||||
expect(screen.getByTestId("task-A")).toBeTruthy();
|
||||
expect(screen.queryByTestId("task-B")).toBeNull();
|
||||
expect(screen.queryByTestId("task-C")).toBeNull();
|
||||
});
|
||||
|
||||
it("fit-to-screen button triggers fitToGraph", () => {
|
||||
render(<DependencyGraph tasks={[createTask("A", "todo")]} onOpenTaskDetail={vi.fn()} />);
|
||||
fireEvent.click(screen.getByRole("button", { name: "Fit to screen" }));
|
||||
expect(fitToGraph).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -1,261 +0,0 @@
|
||||
import { describe, expect, it, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, fireEvent, screen, act, cleanup } from "@testing-library/react";
|
||||
import * as React from "react";
|
||||
import { DependencyGraphView } from "../DependencyGraphView";
|
||||
import type { DependencyGraphHostContext, PluginDashboardViewComponentProps } from "../DependencyGraphView";
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
// Mock storage to avoid localStorage dependency
|
||||
vi.mock("../storage", () => ({
|
||||
loadPositions: () => ({}),
|
||||
savePositions: () => {},
|
||||
}));
|
||||
|
||||
// Helper to create a minimal Task
|
||||
function createTask(overrides: Partial<Task> & { id: string; column: Task["column"] }): Task {
|
||||
return {
|
||||
description: overrides.description ?? `Task ${overrides.id}`,
|
||||
column: overrides.column,
|
||||
dependencies: overrides.dependencies ?? [],
|
||||
steps: overrides.steps ?? [],
|
||||
currentStep: overrides.currentStep ?? 0,
|
||||
log: overrides.log ?? [],
|
||||
...overrides,
|
||||
} as Task;
|
||||
}
|
||||
|
||||
function createMockContext(tasks: Task[] = []): DependencyGraphHostContext {
|
||||
return {
|
||||
projectId: "test-project",
|
||||
tasks,
|
||||
openTaskDetail: vi.fn(),
|
||||
renderTaskCard: (task: Task) => React.createElement("div", { "data-testid": "task-card" }, task.id),
|
||||
};
|
||||
}
|
||||
|
||||
function renderView(context?: DependencyGraphHostContext) {
|
||||
const props: PluginDashboardViewComponentProps = {
|
||||
context: context ?? createMockContext(),
|
||||
};
|
||||
return render(React.createElement(DependencyGraphView, props));
|
||||
}
|
||||
|
||||
describe("DependencyGraphView", () => {
|
||||
beforeEach(() => {
|
||||
// Mock getComputedStyle for rem calculations
|
||||
vi.spyOn(window, "getComputedStyle").mockImplementation((() => {
|
||||
return { fontSize: "16px" } as CSSStyleDeclaration;
|
||||
}) as typeof window.getComputedStyle);
|
||||
|
||||
// Mock matchMedia to default to desktop
|
||||
window.matchMedia = vi.fn().mockImplementation((query: string) => ({
|
||||
matches: false,
|
||||
media: query,
|
||||
onchange: null,
|
||||
addListener: vi.fn(),
|
||||
removeListener: vi.fn(),
|
||||
addEventListener: vi.fn(),
|
||||
removeEventListener: vi.fn(),
|
||||
dispatchEvent: vi.fn(),
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("renders empty state when no active-column tasks provided", () => {
|
||||
const { container } = renderView(createMockContext([]));
|
||||
const empty = container.querySelector(".dependency-graph-empty");
|
||||
expect(empty).toBeTruthy();
|
||||
expect(empty?.textContent).toContain("No tasks to display");
|
||||
});
|
||||
|
||||
it("renders nodes for active-column tasks", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", column: "todo" }),
|
||||
createTask({ id: "FN-002", column: "in-progress" }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const nodes = container.querySelectorAll(".dependency-graph-node");
|
||||
expect(nodes.length).toBe(2);
|
||||
});
|
||||
|
||||
it("filters out tasks not in active columns", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", column: "todo" }),
|
||||
createTask({ id: "FN-002", column: "done" }),
|
||||
createTask({ id: "FN-003", column: "archived" }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const nodes = container.querySelectorAll(".dependency-graph-node");
|
||||
expect(nodes.length).toBe(1);
|
||||
});
|
||||
|
||||
it("renders edges for tasks with dependencies in the active set", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-001", column: "todo" }),
|
||||
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-001"] }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const edges = container.querySelectorAll(".dependency-graph-edge");
|
||||
expect(edges.length).toBe(1);
|
||||
});
|
||||
|
||||
it("does not render edges for dependencies not in the active set", () => {
|
||||
const tasks = [
|
||||
createTask({ id: "FN-002", column: "todo", dependencies: ["FN-999"] }),
|
||||
];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const edges = container.querySelectorAll(".dependency-graph-edge");
|
||||
expect(edges.length).toBe(0);
|
||||
});
|
||||
|
||||
it("zoom-in button increases scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
|
||||
fireEvent.click(zoomInBtn);
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
expect(scene).toBeTruthy();
|
||||
const transform = scene.style.transform;
|
||||
expect(transform).toMatch(/scale\([1-9]/);
|
||||
});
|
||||
|
||||
it("zoom-out button decreases scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
|
||||
fireEvent.click(zoomOutBtn);
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
expect(scene).toBeTruthy();
|
||||
const transform = scene.style.transform;
|
||||
expect(transform).toMatch(/scale\(0\./);
|
||||
});
|
||||
|
||||
it("fit-to-graph button produces a valid scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
|
||||
// Mock canvas dimensions for fitToGraph
|
||||
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
|
||||
if (canvas) {
|
||||
Object.defineProperty(canvas, "clientWidth", { value: 800, configurable: true });
|
||||
Object.defineProperty(canvas, "clientHeight", { value: 600, configurable: true });
|
||||
}
|
||||
|
||||
const fitBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Fit")!;
|
||||
fireEvent.click(fitBtn);
|
||||
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
expect(scene).toBeTruthy();
|
||||
const transform = scene.style.transform;
|
||||
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
const scaleValue = parseFloat(scaleMatch![1]);
|
||||
expect(scaleValue).toBeGreaterThanOrEqual(0.4);
|
||||
expect(scaleValue).toBeLessThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("wheel event zooms the graph", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
|
||||
|
||||
expect(canvas).toBeTruthy();
|
||||
|
||||
// Mock getBoundingClientRect on the canvas
|
||||
canvas.getBoundingClientRect = vi.fn().mockReturnValue({
|
||||
left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, x: 0, y: 0,
|
||||
});
|
||||
|
||||
const initialScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
const initialTransform = initialScene.style.transform;
|
||||
|
||||
// Negative deltaY = zoom in
|
||||
fireEvent.wheel(canvas, { deltaY: -100, clientX: 400, clientY: 300 });
|
||||
|
||||
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
const updatedTransform = updatedScene.style.transform;
|
||||
|
||||
expect(updatedTransform).not.toBe(initialTransform);
|
||||
const scaleMatch = updatedTransform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("pinch gesture changes scale", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container } = renderView(createMockContext(tasks));
|
||||
const canvas = container.querySelector(".dependency-graph-canvas") as HTMLElement;
|
||||
|
||||
expect(canvas).toBeTruthy();
|
||||
|
||||
// First pointer down (finger 1)
|
||||
fireEvent.pointerDown(canvas, {
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
button: 0,
|
||||
clientX: 200,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
// Second pointer down (finger 2) — starts pinch
|
||||
fireEvent.pointerDown(canvas, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
button: 0,
|
||||
clientX: 600,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
// Move fingers apart (increasing distance)
|
||||
fireEvent.pointerMove(canvas, {
|
||||
pointerId: 1,
|
||||
pointerType: "touch",
|
||||
clientX: 100,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
fireEvent.pointerMove(canvas, {
|
||||
pointerId: 2,
|
||||
pointerType: "touch",
|
||||
clientX: 700,
|
||||
clientY: 300,
|
||||
});
|
||||
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
const transform = scene.style.transform;
|
||||
const scaleMatch = transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeGreaterThan(1);
|
||||
});
|
||||
|
||||
it("scale is clamped between MIN_SCALE and MAX_SCALE", () => {
|
||||
const tasks = [createTask({ id: "FN-001", column: "todo" })];
|
||||
const { container, unmount } = renderView(createMockContext(tasks));
|
||||
|
||||
// Try to zoom in beyond MAX_SCALE (2.0)
|
||||
const zoomInBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom In")!;
|
||||
for (let i = 0; i < 50; i++) {
|
||||
fireEvent.click(zoomInBtn);
|
||||
}
|
||||
|
||||
const scene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
let scaleMatch = scene.style.transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeLessThanOrEqual(2);
|
||||
|
||||
// Try to zoom out beyond MIN_SCALE (0.4)
|
||||
const zoomOutBtn = Array.from(container.querySelectorAll("button")).find((b) => b.textContent === "Zoom Out")!;
|
||||
for (let i = 0; i < 100; i++) {
|
||||
fireEvent.click(zoomOutBtn);
|
||||
}
|
||||
|
||||
const updatedScene = container.querySelector(".dependency-graph-scene") as HTMLElement;
|
||||
scaleMatch = updatedScene.style.transform.match(/scale\(([\d.]+)\)/);
|
||||
expect(scaleMatch).toBeTruthy();
|
||||
expect(parseFloat(scaleMatch![1])).toBeGreaterThanOrEqual(0.4);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { cleanup, render, screen } from "@testing-library/react";
|
||||
import { GraphEdges } from "../edges";
|
||||
import type { GraphEdge } from "../types";
|
||||
|
||||
function renderEdges(edges: GraphEdge[], highlightedEdgeIds?: Set<string>) {
|
||||
const positions = new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 320, y: 180 }],
|
||||
["C", { x: 640, y: 180 }],
|
||||
]);
|
||||
|
||||
return render(
|
||||
<GraphEdges
|
||||
edges={edges}
|
||||
positions={positions}
|
||||
highlightedEdgeIds={highlightedEdgeIds}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
describe("GraphEdges", () => {
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
});
|
||||
it("renders single edge", () => {
|
||||
renderEdges([{ source: "A", target: "B" }]);
|
||||
const edge = screen.getAllByTestId("dependency-edge")[0];
|
||||
expect(edge.getAttribute("opacity")).toBe("1");
|
||||
expect(edge.getAttribute("stroke")).toBe("var(--border)");
|
||||
});
|
||||
|
||||
it("renders multiple edges", () => {
|
||||
renderEdges([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
]);
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("supports edges with same source", () => {
|
||||
renderEdges([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
]);
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("supports edges with same target", () => {
|
||||
renderEdges([
|
||||
{ source: "B", target: "A" },
|
||||
{ source: "C", target: "A" },
|
||||
]);
|
||||
expect(screen.getAllByTestId("dependency-edge")).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dims non-highlighted edges when highlight set provided", () => {
|
||||
renderEdges(
|
||||
[
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
],
|
||||
new Set(["A->B"]),
|
||||
);
|
||||
|
||||
const all = screen.getAllByTestId("dependency-edge");
|
||||
const highlighted = all.find((edge) => edge.getAttribute("data-edge-id") === "A->B");
|
||||
const dimmed = all.find((edge) => edge.getAttribute("data-edge-id") === "A->C");
|
||||
|
||||
expect(highlighted?.getAttribute("opacity")).toBe("1");
|
||||
expect(dimmed?.getAttribute("opacity")).toBe("0.2");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { filterGraphTasks } from "../filters";
|
||||
|
||||
function createTask(id: string, column: Task["column"], dependencies: string[] = []): Task {
|
||||
return {
|
||||
id,
|
||||
description: `Task ${id}`,
|
||||
column,
|
||||
dependencies,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("filterGraphTasks", () => {
|
||||
it("returns empty for empty input", () => {
|
||||
expect(filterGraphTasks([])).toEqual([]);
|
||||
});
|
||||
|
||||
it("includes triage/todo/in-progress/in-review and excludes done/archived", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "triage"),
|
||||
createTask("FN-2", "todo"),
|
||||
createTask("FN-3", "in-progress"),
|
||||
createTask("FN-4", "in-review"),
|
||||
createTask("FN-5", "done"),
|
||||
createTask("FN-6", "archived"),
|
||||
];
|
||||
|
||||
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-1", "FN-2", "FN-3", "FN-4"]);
|
||||
});
|
||||
|
||||
it("returns empty when only excluded columns are present", () => {
|
||||
const tasks = [createTask("FN-1", "done"), createTask("FN-2", "archived")];
|
||||
|
||||
expect(filterGraphTasks(tasks)).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps included tasks even when dependencies reference excluded tasks", () => {
|
||||
const tasks = [
|
||||
createTask("FN-1", "done"),
|
||||
createTask("FN-2", "todo", ["FN-1"]),
|
||||
createTask("FN-3", "in-review", ["FN-2", "FN-1"]),
|
||||
createTask("FN-4", "archived", ["FN-2"]),
|
||||
];
|
||||
|
||||
expect(filterGraphTasks(tasks).map((task) => task.id)).toEqual(["FN-2", "FN-3"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,70 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { GraphData } from "../types";
|
||||
import { computeAutoLayout } from "../layout";
|
||||
|
||||
function graph(nodeIds: string[], edges: Array<{ source: string; target: string }> = []): GraphData {
|
||||
return {
|
||||
nodes: nodeIds.map((id) => ({ task: { id } as never })),
|
||||
edges,
|
||||
};
|
||||
}
|
||||
|
||||
describe("computeAutoLayout", () => {
|
||||
it("returns empty map for empty graph", () => {
|
||||
expect(computeAutoLayout({ nodes: [], edges: [] }).size).toBe(0);
|
||||
});
|
||||
|
||||
it("positions single node", () => {
|
||||
const positions = computeAutoLayout(graph(["A"]));
|
||||
expect(positions.has("A")).toBe(true);
|
||||
});
|
||||
|
||||
it("places linear chain in increasing depth", () => {
|
||||
const positions = computeAutoLayout(graph(["A", "B", "C"], [
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "B", target: "C" },
|
||||
]));
|
||||
|
||||
expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
|
||||
expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
|
||||
});
|
||||
|
||||
it("spreads wide layer horizontally", () => {
|
||||
const positions = computeAutoLayout(graph(["A", "B", "C"]));
|
||||
const xs = [positions.get("A")?.x, positions.get("B")?.x, positions.get("C")?.x].filter((x): x is number => x !== undefined);
|
||||
expect(new Set(xs).size).toBe(3);
|
||||
});
|
||||
|
||||
it("handles diamond dependencies", () => {
|
||||
const positions = computeAutoLayout(graph(["A", "B", "C", "D"], [
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
{ source: "B", target: "D" },
|
||||
{ source: "C", target: "D" },
|
||||
]));
|
||||
|
||||
expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("B")?.y ?? 0);
|
||||
expect((positions.get("D")?.y ?? 0)).toBeLessThan(positions.get("C")?.y ?? 0);
|
||||
expect((positions.get("B")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
|
||||
expect((positions.get("C")?.y ?? 0)).toBeLessThan(positions.get("A")?.y ?? 0);
|
||||
});
|
||||
|
||||
it("handles cycles without crashing", () => {
|
||||
const positions = computeAutoLayout(graph(["A", "B"], [
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "B", target: "A" },
|
||||
]));
|
||||
|
||||
expect(positions.size).toBe(2);
|
||||
});
|
||||
|
||||
it("respects custom spacing options", () => {
|
||||
const positions = computeAutoLayout(graph(["A", "B"]), {
|
||||
nodeWidth: 200,
|
||||
nodeHeight: 120,
|
||||
horizontalGap: 100,
|
||||
verticalGap: 20,
|
||||
});
|
||||
expect(Math.abs((positions.get("A")?.x ?? 0) - (positions.get("B")?.x ?? 0))).toBe(300);
|
||||
});
|
||||
});
|
||||
@@ -1,41 +0,0 @@
|
||||
import { describe, expect, it, beforeEach } from "vitest";
|
||||
import { projectScopedKey, loadPositions, savePositions } from "../storage";
|
||||
|
||||
const createMemoryStorage = () => {
|
||||
const map = new Map<string, string>();
|
||||
return {
|
||||
getItem: (key: string) => map.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => {
|
||||
map.set(key, value);
|
||||
},
|
||||
clear: () => map.clear(),
|
||||
};
|
||||
};
|
||||
|
||||
describe("storage", () => {
|
||||
const localStorage = createMemoryStorage();
|
||||
|
||||
beforeEach(() => {
|
||||
(globalThis as { window?: { localStorage?: typeof localStorage } }).window = { localStorage };
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
it("builds project-scoped key with canonical base key", () => {
|
||||
expect(projectScopedKey("proj_123")).toBe("kb:proj_123:fusion-plugin-dependency-graph:positions");
|
||||
});
|
||||
|
||||
it("falls back to unscoped key when projectId is missing or empty", () => {
|
||||
expect(projectScopedKey()).toBe("fusion-plugin-dependency-graph:positions");
|
||||
expect(projectScopedKey("")).toBe("fusion-plugin-dependency-graph:positions");
|
||||
});
|
||||
|
||||
it("persists and restores positions", () => {
|
||||
savePositions("proj_123", { "FN-1": { x: 10, y: 20 } });
|
||||
expect(loadPositions("proj_123")).toEqual({ "FN-1": { x: 10, y: 20 } });
|
||||
});
|
||||
|
||||
it("returns empty object for invalid JSON", () => {
|
||||
localStorage.setItem("kb:proj_123:fusion-plugin-dependency-graph:positions", "not-json");
|
||||
expect(loadPositions("proj_123")).toEqual({});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,68 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import { useGraphData } from "../useGraphData";
|
||||
|
||||
function createTask(id: string, dependencies: string[] = []): Task {
|
||||
return {
|
||||
id,
|
||||
description: id,
|
||||
column: "todo",
|
||||
dependencies,
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
} as Task;
|
||||
}
|
||||
|
||||
describe("useGraphData", () => {
|
||||
it("returns empty graph for empty tasks", () => {
|
||||
const { result } = renderHook(() => useGraphData([]));
|
||||
expect(result.current).toEqual({ nodes: [], edges: [] });
|
||||
});
|
||||
|
||||
it("creates node for single task with no deps", () => {
|
||||
const { result } = renderHook(() => useGraphData([createTask("A")]));
|
||||
expect(result.current.nodes.map((node) => node.task.id)).toEqual(["A"]);
|
||||
expect(result.current.edges).toEqual([]);
|
||||
});
|
||||
|
||||
it("creates edges in dependent-to-dependency direction for chain", () => {
|
||||
const tasks = [createTask("A", ["B"]), createTask("B", ["C"]), createTask("C")];
|
||||
const { result } = renderHook(() => useGraphData(tasks));
|
||||
expect(result.current.edges).toEqual([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "B", target: "C" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("creates diamond dependency edges", () => {
|
||||
const tasks = [
|
||||
createTask("A", ["B", "C"]),
|
||||
createTask("B", ["D"]),
|
||||
createTask("C", ["D"]),
|
||||
createTask("D"),
|
||||
];
|
||||
const { result } = renderHook(() => useGraphData(tasks));
|
||||
expect(result.current.edges).toEqual([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "A", target: "C" },
|
||||
{ source: "B", target: "D" },
|
||||
{ source: "C", target: "D" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("drops orphan dependency references", () => {
|
||||
const { result } = renderHook(() => useGraphData([createTask("A", ["Z"]), createTask("B", ["A"])]));
|
||||
expect(result.current.edges).toEqual([{ source: "B", target: "A" }]);
|
||||
});
|
||||
|
||||
it("supports disconnected subgraphs", () => {
|
||||
const tasks = [createTask("A", ["B"]), createTask("B"), createTask("X", ["Y"]), createTask("Y")];
|
||||
const { result } = renderHook(() => useGraphData(tasks));
|
||||
expect(result.current.edges).toEqual([
|
||||
{ source: "A", target: "B" },
|
||||
{ source: "X", target: "Y" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { act, renderHook } from "@testing-library/react";
|
||||
import { useGraphInteraction } from "../useGraphInteraction";
|
||||
|
||||
describe("useGraphInteraction", () => {
|
||||
it("starts with default pan/zoom", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("clamps zoom between 0.1 and 3", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 100; i += 1) result.current.zoomOut();
|
||||
});
|
||||
expect(result.current.zoom).toBe(0.1);
|
||||
|
||||
act(() => {
|
||||
for (let i = 0; i < 100; i += 1) result.current.zoomIn();
|
||||
});
|
||||
expect(result.current.zoom).toBe(3);
|
||||
});
|
||||
|
||||
it("fits single node", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([["A", { x: 0, y: 0 }]]), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeGreaterThan(0.1);
|
||||
expect(result.current.zoom).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it("fits wide graph", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 2000, y: 0 }],
|
||||
]), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("fits tall graph", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
act(() => {
|
||||
result.current.fitToGraph(new Map([
|
||||
["A", { x: 0, y: 0 }],
|
||||
["B", { x: 0, y: 2000 }],
|
||||
]), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBeLessThan(1);
|
||||
});
|
||||
|
||||
it("resets when positions are empty", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn();
|
||||
result.current.onPointerDown(1, { x: 10, y: 10 });
|
||||
result.current.onPointerMove(1, { x: 110, y: 60 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
result.current.fitToGraph(new Map(), 800, 600);
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
|
||||
it("resetView restores defaults", () => {
|
||||
const { result } = renderHook(() => useGraphInteraction());
|
||||
|
||||
act(() => {
|
||||
result.current.zoomIn();
|
||||
result.current.onPointerDown(1, { x: 0, y: 0 });
|
||||
result.current.onPointerMove(1, { x: 200, y: 200 }, 800, 600);
|
||||
result.current.onPointerUp(1);
|
||||
result.current.resetView();
|
||||
});
|
||||
|
||||
expect(result.current.zoom).toBe(1);
|
||||
expect(result.current.pan).toEqual({ x: 0, y: 0 });
|
||||
});
|
||||
});
|
||||
14
plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts
vendored
Normal file
14
plugins/fusion-plugin-dependency-graph/src/dashboard-interop.d.ts
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
declare module "@fusion/dashboard/app/components/TaskCard" {
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { ReactElement } from "react";
|
||||
|
||||
interface TaskCardProps {
|
||||
task: Task;
|
||||
projectId?: string;
|
||||
onOpenDetail: (task: Task) => void;
|
||||
addToast: (message: string, type?: "success" | "error" | "info") => void;
|
||||
disableDrag?: boolean;
|
||||
}
|
||||
|
||||
export function TaskCard(props: TaskCardProps): ReactElement;
|
||||
}
|
||||
69
plugins/fusion-plugin-dependency-graph/src/edges.tsx
Normal file
69
plugins/fusion-plugin-dependency-graph/src/edges.tsx
Normal file
@@ -0,0 +1,69 @@
|
||||
import type { GraphEdge, GraphPosition } from "./types";
|
||||
|
||||
interface GraphEdgesProps {
|
||||
edges: GraphEdge[];
|
||||
positions: Map<string, GraphPosition>;
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
highlightedEdgeIds?: Set<string>;
|
||||
}
|
||||
|
||||
const DEFAULT_NODE_WIDTH = 280;
|
||||
const DEFAULT_NODE_HEIGHT = 100;
|
||||
|
||||
export function GraphEdges({
|
||||
edges,
|
||||
positions,
|
||||
nodeWidth = DEFAULT_NODE_WIDTH,
|
||||
nodeHeight = DEFAULT_NODE_HEIGHT,
|
||||
highlightedEdgeIds,
|
||||
}: GraphEdgesProps) {
|
||||
const hasHighlights = Boolean(highlightedEdgeIds && highlightedEdgeIds.size > 0);
|
||||
|
||||
return (
|
||||
<svg className="dependency-graph-edges" aria-hidden="true">
|
||||
<defs>
|
||||
<marker
|
||||
id="dependency-graph-arrowhead"
|
||||
markerWidth="10"
|
||||
markerHeight="7"
|
||||
refX="10"
|
||||
refY="3.5"
|
||||
orient="auto"
|
||||
markerUnits="strokeWidth"
|
||||
>
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="var(--border)" />
|
||||
</marker>
|
||||
</defs>
|
||||
{edges.map((edge) => {
|
||||
const source = positions.get(edge.source);
|
||||
const target = positions.get(edge.target);
|
||||
if (!source || !target) return null;
|
||||
|
||||
const edgeId = `${edge.source}->${edge.target}`;
|
||||
const isActiveHighlight = hasHighlights && (highlightedEdgeIds?.has(edgeId) ?? false);
|
||||
const x1 = source.x + nodeWidth / 2;
|
||||
const y1 = source.y + nodeHeight;
|
||||
const x2 = target.x + nodeWidth / 2;
|
||||
const y2 = target.y;
|
||||
const controlY = y1 + (y2 - y1) / 2;
|
||||
|
||||
return (
|
||||
<path
|
||||
key={edgeId}
|
||||
data-testid="dependency-edge"
|
||||
data-edge-id={edgeId}
|
||||
className={`dependency-graph-edge${isActiveHighlight ? " is-related" : ""}${hasHighlights && !isActiveHighlight ? " is-dimmed" : ""}`}
|
||||
d={`M ${x1} ${y1} C ${x1} ${controlY}, ${x2} ${controlY}, ${x2} ${y2}`}
|
||||
fill="none"
|
||||
stroke={isActiveHighlight ? "var(--text-muted)" : "var(--border)"}
|
||||
strokeWidth="1"
|
||||
opacity={hasHighlights && !isActiveHighlight ? 0.2 : 1}
|
||||
markerEnd="url(#dependency-graph-arrowhead)"
|
||||
style={{ transition: "opacity var(--transition-fast), stroke var(--transition-fast)" }}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
7
plugins/fusion-plugin-dependency-graph/src/filters.ts
Normal file
7
plugins/fusion-plugin-dependency-graph/src/filters.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
const INCLUDED_COLUMNS = new Set<Task["column"]>(["triage", "todo", "in-progress", "in-review"]);
|
||||
|
||||
export function filterGraphTasks(tasks: Task[]): Task[] {
|
||||
return tasks.filter((task) => INCLUDED_COLUMNS.has(task.column));
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const plugin = definePlugin({
|
||||
{
|
||||
viewId: "graph",
|
||||
label: "Graph",
|
||||
componentPath: "./src/DependencyGraphView.tsx",
|
||||
componentPath: "./src/DependencyGraph.tsx",
|
||||
icon: "Network",
|
||||
placement: "more",
|
||||
order: 40,
|
||||
@@ -22,4 +22,4 @@ const plugin = definePlugin({
|
||||
});
|
||||
|
||||
export default plugin;
|
||||
export { DependencyGraphView } from "./DependencyGraphView";
|
||||
export { DependencyGraph } from "./DependencyGraph";
|
||||
|
||||
91
plugins/fusion-plugin-dependency-graph/src/layout.ts
Normal file
91
plugins/fusion-plugin-dependency-graph/src/layout.ts
Normal file
@@ -0,0 +1,91 @@
|
||||
import type { GraphData, GraphPosition } from "./types";
|
||||
|
||||
export interface LayoutOptions {
|
||||
nodeWidth?: number;
|
||||
nodeHeight?: number;
|
||||
horizontalGap?: number;
|
||||
verticalGap?: number;
|
||||
}
|
||||
|
||||
const DEFAULT_LAYOUT_OPTIONS: Required<LayoutOptions> = {
|
||||
nodeWidth: 280,
|
||||
nodeHeight: 100,
|
||||
horizontalGap: 40,
|
||||
verticalGap: 80,
|
||||
};
|
||||
|
||||
export function computeAutoLayout(
|
||||
graphData: GraphData,
|
||||
options?: LayoutOptions,
|
||||
): Map<string, GraphPosition> {
|
||||
const settings = { ...DEFAULT_LAYOUT_OPTIONS, ...options };
|
||||
const nodeIds = graphData.nodes.map((node) => node.task.id);
|
||||
if (nodeIds.length === 0) return new Map();
|
||||
|
||||
const dependentsByDependency = new Map<string, string[]>();
|
||||
const inDegree = new Map<string, number>();
|
||||
|
||||
for (const id of nodeIds) {
|
||||
inDegree.set(id, 0);
|
||||
dependentsByDependency.set(id, []);
|
||||
}
|
||||
|
||||
for (const edge of graphData.edges) {
|
||||
if (!inDegree.has(edge.source) || !inDegree.has(edge.target)) continue;
|
||||
dependentsByDependency.get(edge.target)?.push(edge.source);
|
||||
inDegree.set(edge.source, (inDegree.get(edge.source) ?? 0) + 1);
|
||||
}
|
||||
|
||||
const queue = nodeIds.filter((id) => (inDegree.get(id) ?? 0) === 0);
|
||||
const topologicalOrder: string[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift()!;
|
||||
topologicalOrder.push(current);
|
||||
for (const dependent of dependentsByDependency.get(current) ?? []) {
|
||||
const nextInDegree = (inDegree.get(dependent) ?? 0) - 1;
|
||||
inDegree.set(dependent, nextInDegree);
|
||||
if (nextInDegree === 0) queue.push(dependent);
|
||||
}
|
||||
}
|
||||
|
||||
for (const id of nodeIds) {
|
||||
if (!topologicalOrder.includes(id)) topologicalOrder.push(id);
|
||||
}
|
||||
|
||||
const depthByNode = new Map<string, number>();
|
||||
for (const id of topologicalOrder) {
|
||||
const parents = graphData.edges.filter((edge) => edge.source === id).map((edge) => edge.target);
|
||||
let depth = 0;
|
||||
for (const parent of parents) {
|
||||
depth = Math.max(depth, (depthByNode.get(parent) ?? 0) + 1);
|
||||
}
|
||||
depthByNode.set(id, depth);
|
||||
}
|
||||
|
||||
const layers = new Map<number, string[]>();
|
||||
for (const id of nodeIds) {
|
||||
const depth = depthByNode.get(id) ?? 0;
|
||||
const layer = layers.get(depth) ?? [];
|
||||
layer.push(id);
|
||||
layers.set(depth, layer);
|
||||
}
|
||||
|
||||
const positions = new Map<string, GraphPosition>();
|
||||
const sortedDepths = Array.from(layers.keys()).sort((a, b) => a - b);
|
||||
|
||||
for (const depth of sortedDepths) {
|
||||
const layer = layers.get(depth) ?? [];
|
||||
layer.sort();
|
||||
const layerWidth = layer.length * settings.nodeWidth + Math.max(0, layer.length - 1) * settings.horizontalGap;
|
||||
const startX = -layerWidth / 2;
|
||||
|
||||
layer.forEach((id, index) => {
|
||||
const x = startX + index * (settings.nodeWidth + settings.horizontalGap);
|
||||
const y = depth * (settings.nodeHeight + settings.verticalGap);
|
||||
positions.set(id, { x, y });
|
||||
});
|
||||
}
|
||||
|
||||
return positions;
|
||||
}
|
||||
@@ -1,22 +0,0 @@
|
||||
import { getScopedItem, scopedKey, setScopedItem } from "../../../packages/dashboard/app/utils/projectStorage";
|
||||
|
||||
const BASE_KEY = "fusion-plugin-dependency-graph:positions";
|
||||
|
||||
export function projectScopedKey(projectId?: string): string {
|
||||
return scopedKey(BASE_KEY, projectId);
|
||||
}
|
||||
|
||||
export function loadPositions(projectId?: string): Record<string, { x: number; y: number }> {
|
||||
try {
|
||||
const raw = getScopedItem(BASE_KEY, projectId);
|
||||
if (!raw) return {};
|
||||
const parsed = JSON.parse(raw) as Record<string, { x: number; y: number }>;
|
||||
return parsed ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
export function savePositions(projectId: string | undefined, positions: Record<string, { x: number; y: number }>): void {
|
||||
setScopedItem(BASE_KEY, JSON.stringify(positions), projectId);
|
||||
}
|
||||
21
plugins/fusion-plugin-dependency-graph/src/types.ts
Normal file
21
plugins/fusion-plugin-dependency-graph/src/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import type { Task } from "@fusion/core";
|
||||
|
||||
export interface GraphPosition {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface GraphNode {
|
||||
task: Task;
|
||||
position?: GraphPosition;
|
||||
}
|
||||
|
||||
export interface GraphEdge {
|
||||
source: string;
|
||||
target: string;
|
||||
}
|
||||
|
||||
export interface GraphData {
|
||||
nodes: GraphNode[];
|
||||
edges: GraphEdge[];
|
||||
}
|
||||
17
plugins/fusion-plugin-dependency-graph/src/useGraphData.ts
Normal file
17
plugins/fusion-plugin-dependency-graph/src/useGraphData.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Task } from "@fusion/core";
|
||||
import type { GraphData, GraphNode } from "./types";
|
||||
|
||||
export function useGraphData(tasks: Task[]): GraphData {
|
||||
return useMemo(() => {
|
||||
const nodes: GraphNode[] = tasks.map((task) => ({ task }));
|
||||
const taskIds = new Set(tasks.map((task) => task.id));
|
||||
const edges = tasks.flatMap((task) =>
|
||||
(task.dependencies ?? [])
|
||||
.filter((dependencyId) => taskIds.has(dependencyId))
|
||||
.map((dependencyId) => ({ source: task.id, target: dependencyId })),
|
||||
);
|
||||
|
||||
return { nodes, edges };
|
||||
}, [tasks]);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import type { LayoutOptions } from "./layout";
|
||||
import type { GraphPosition } from "./types";
|
||||
|
||||
const MIN_ZOOM = 0.1;
|
||||
const MAX_ZOOM = 3;
|
||||
const FIT_PADDING = 40;
|
||||
|
||||
interface PointerPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
}
|
||||
|
||||
function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(max, Math.max(min, value));
|
||||
}
|
||||
|
||||
export function useGraphInteraction() {
|
||||
const [pan, setPan] = useState<PointerPoint>({ x: 0, y: 0 });
|
||||
const [zoom, setZoom] = useState(1);
|
||||
const dragStateRef = useRef<{ start: PointerPoint; panStart: PointerPoint } | null>(null);
|
||||
const pointersRef = useRef<Map<number, PointerPoint>>(new Map());
|
||||
const pinchRef = useRef<{ distance: number; zoom: number } | null>(null);
|
||||
|
||||
const transform = useMemo(() => `translate(${pan.x}px, ${pan.y}px) scale(${zoom})`, [pan.x, pan.y, zoom]);
|
||||
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoom((current) => clamp(current + 0.1, MIN_ZOOM, MAX_ZOOM));
|
||||
}, []);
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoom((current) => clamp(current - 0.1, MIN_ZOOM, MAX_ZOOM));
|
||||
}, []);
|
||||
|
||||
const resetView = useCallback(() => {
|
||||
setPan({ x: 0, y: 0 });
|
||||
setZoom(1);
|
||||
}, []);
|
||||
|
||||
const clampPan = useCallback((nextPan: PointerPoint, viewportWidth: number, viewportHeight: number) => ({
|
||||
x: clamp(nextPan.x, -viewportWidth, viewportWidth),
|
||||
y: clamp(nextPan.y, -viewportHeight, viewportHeight),
|
||||
}), []);
|
||||
|
||||
const fitToGraph = useCallback((
|
||||
positions: Map<string, GraphPosition>,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
layoutOptions?: LayoutOptions,
|
||||
) => {
|
||||
if (positions.size === 0) {
|
||||
resetView();
|
||||
return;
|
||||
}
|
||||
|
||||
const nodeWidth = layoutOptions?.nodeWidth ?? 280;
|
||||
const nodeHeight = layoutOptions?.nodeHeight ?? 100;
|
||||
|
||||
const entries = Array.from(positions.values());
|
||||
const minX = Math.min(...entries.map((p) => p.x));
|
||||
const minY = Math.min(...entries.map((p) => p.y));
|
||||
const maxX = Math.max(...entries.map((p) => p.x + nodeWidth));
|
||||
const maxY = Math.max(...entries.map((p) => p.y + nodeHeight));
|
||||
|
||||
const graphWidth = Math.max(1, maxX - minX);
|
||||
const graphHeight = Math.max(1, maxY - minY);
|
||||
const availableWidth = Math.max(1, viewportWidth - FIT_PADDING * 2);
|
||||
const availableHeight = Math.max(1, viewportHeight - FIT_PADDING * 2);
|
||||
const nextZoom = clamp(Math.min(availableWidth / graphWidth, availableHeight / graphHeight), MIN_ZOOM, MAX_ZOOM);
|
||||
|
||||
const panX = (viewportWidth - graphWidth * nextZoom) / 2 - minX * nextZoom;
|
||||
const panY = (viewportHeight - graphHeight * nextZoom) / 2 - minY * nextZoom;
|
||||
|
||||
setZoom(nextZoom);
|
||||
setPan(clampPan({ x: panX, y: panY }, viewportWidth, viewportHeight));
|
||||
}, [clampPan, resetView]);
|
||||
|
||||
const onPointerDown = useCallback((pointerId: number, point: PointerPoint) => {
|
||||
pointersRef.current.set(pointerId, point);
|
||||
if (pointersRef.current.size === 2) {
|
||||
const [a, b] = Array.from(pointersRef.current.values());
|
||||
pinchRef.current = { distance: Math.hypot(a.x - b.x, a.y - b.y), zoom };
|
||||
dragStateRef.current = null;
|
||||
return;
|
||||
}
|
||||
dragStateRef.current = { start: point, panStart: pan };
|
||||
}, [pan, zoom]);
|
||||
|
||||
const onPointerMove = useCallback((pointerId: number, point: PointerPoint, viewportWidth: number, viewportHeight: number) => {
|
||||
if (pointersRef.current.has(pointerId)) pointersRef.current.set(pointerId, point);
|
||||
|
||||
if (pointersRef.current.size >= 2 && pinchRef.current) {
|
||||
const [a, b] = Array.from(pointersRef.current.values());
|
||||
const distance = Math.hypot(a.x - b.x, a.y - b.y);
|
||||
const factor = distance / Math.max(1, pinchRef.current.distance);
|
||||
setZoom(clamp(pinchRef.current.zoom * factor, MIN_ZOOM, MAX_ZOOM));
|
||||
return;
|
||||
}
|
||||
|
||||
const dragState = dragStateRef.current;
|
||||
if (!dragState) return;
|
||||
const nextPan = {
|
||||
x: dragState.panStart.x + (point.x - dragState.start.x),
|
||||
y: dragState.panStart.y + (point.y - dragState.start.y),
|
||||
};
|
||||
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
|
||||
}, [clampPan]);
|
||||
|
||||
const onPointerUp = useCallback((pointerId: number) => {
|
||||
pointersRef.current.delete(pointerId);
|
||||
if (pointersRef.current.size < 2) pinchRef.current = null;
|
||||
if (pointersRef.current.size === 0) dragStateRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onWheelZoom = useCallback((
|
||||
deltaY: number,
|
||||
point: PointerPoint,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number,
|
||||
) => {
|
||||
const factor = deltaY < 0 ? 1.1 : 0.9;
|
||||
const nextZoom = clamp(zoom * factor, MIN_ZOOM, MAX_ZOOM);
|
||||
const scaleRatio = nextZoom / zoom;
|
||||
|
||||
const nextPan = {
|
||||
x: point.x - (point.x - pan.x) * scaleRatio,
|
||||
y: point.y - (point.y - pan.y) * scaleRatio,
|
||||
};
|
||||
|
||||
setZoom(nextZoom);
|
||||
setPan(clampPan(nextPan, viewportWidth, viewportHeight));
|
||||
}, [clampPan, pan.x, pan.y, zoom]);
|
||||
|
||||
return {
|
||||
pan,
|
||||
zoom,
|
||||
transform,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
resetView,
|
||||
fitToGraph,
|
||||
onPointerDown,
|
||||
onPointerMove,
|
||||
onPointerUp,
|
||||
onWheelZoom,
|
||||
};
|
||||
}
|
||||
@@ -8,6 +8,6 @@
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["react"]
|
||||
},
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||
"include": ["src/**/*.ts", "src/**/*.tsx", "src/**/*.d.ts"],
|
||||
"exclude": ["src/__tests__/**"]
|
||||
}
|
||||
@@ -9,6 +9,7 @@ export default defineConfig({
|
||||
alias: {
|
||||
"@fusion/core": fileURLToPath(new URL("../../packages/core/src/index.ts", import.meta.url)),
|
||||
"@fusion/plugin-sdk": fileURLToPath(new URL("../../packages/plugin-sdk/src/index.ts", import.meta.url)),
|
||||
"@fusion/dashboard": fileURLToPath(new URL("../../packages/dashboard", import.meta.url)),
|
||||
},
|
||||
},
|
||||
test: {
|
||||
|
||||
Reference in New Issue
Block a user