"use client"; import { useCallback, useMemo, useState } from "react"; import { ReactFlow, ReactFlowProvider, Background, BackgroundVariant, Controls, Handle, Position, useReactFlow, type Edge, type Node, type NodeProps, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import dagre from "@dagrejs/dagre"; import { toPng } from "html-to-image"; import { Boxes, Database, AppWindow, MapPin, Truck, Server, Package, FolderKanban, Users, FileText, GitBranch, Crosshair, Search, Download, Zap, ZapOff, } from "lucide-react"; import { cn } from "@/lib/utils"; import type { DependencyGraph, GraphNode, GraphNodeKind } from "@/server/dependency-graph"; const KIND_ICON: Record = { process: GitBranch, INFORMATION: FileText, SYSTEM: Boxes, APPLICATION: AppWindow, LOCATION: MapPin, SUPPLIER: Truck, IT_SERVICE: Server, SOFTWARE: Package, PROJECT: FolderKanban, PERSON: Users, DATA: Database, }; type NodeData = GraphNode & { dimmed: boolean; kindLabel: string }; /** Custom-Node im Topology-Look (Referenz ISMS-Abhaengigkeitskarte). */ function IsmsNode({ data }: NodeProps>) { const Icon = KIND_ICON[data.kind] ?? Boxes; const critColor = data.criticality >= 4 ? "#ff6b6b" : data.criticality >= 3 ? "#f0ad4e" : "#39c07f"; return (
{data.name}
{data.kindLabel} · K{data.criticality} {data.spof && ( SPOF )}
); } const nodeTypes = { isms: IsmsNode }; function layout(graph: DependencyGraph, kindLabels: Record) { const g = new dagre.graphlib.Graph(); g.setDefaultEdgeLabel(() => ({})); g.setGraph({ rankdir: "LR", nodesep: 34, ranksep: 130, marginx: 20, marginy: 20 }); graph.nodes.forEach((n) => g.setNode(n.id, { width: 200, height: 64 })); graph.edges.forEach((e) => g.setEdge(e.source, e.target)); dagre.layout(g); const nodes: Node[] = graph.nodes.map((n) => { const p = g.node(n.id); return { id: n.id, type: "isms", position: { x: p.x - 100, y: p.y - 32 }, data: { ...n, dimmed: false, kindLabel: kindLabels[n.kind] ?? n.kind }, }; }); return nodes; } function Canvas({ graph, kindLabels, labels, }: { graph: DependencyGraph; kindLabels: Record; labels: Record; }) { const rf = useReactFlow(); const [showCritical, setShowCritical] = useState(true); const [focus, setFocus] = useState(null); const [query, setQuery] = useState(""); const baseNodes = useMemo(() => layout(graph, kindLabels), [graph, kindLabels]); // Zusammenhang für Fokus-Highlight (ungerichtet) const neighbors = useMemo(() => { const m = new Map>(); for (const e of graph.edges) { if (!m.has(e.source)) m.set(e.source, new Set()); if (!m.has(e.target)) m.set(e.target, new Set()); m.get(e.source)!.add(e.target); m.get(e.target)!.add(e.source); } return m; }, [graph.edges]); const focusSet = useMemo(() => { if (!focus) return null; const seen = new Set([focus]); const stack = [focus]; while (stack.length) { const cur = stack.pop()!; for (const nb of neighbors.get(cur) ?? []) { if (!seen.has(nb)) { seen.add(nb); stack.push(nb); } } } return seen; }, [focus, neighbors]); const nodes: Node[] = useMemo( () => baseNodes.map((n) => ({ ...n, data: { ...n.data, dimmed: focusSet ? !focusSet.has(n.id) : false }, })), [baseNodes, focusSet] ); const edges: Edge[] = useMemo( () => graph.edges.map((e) => { const isCrit = e.critical && showCritical; const inFocus = !focusSet || (focusSet.has(e.source) && focusSet.has(e.target)); return { id: e.id, source: e.source, target: e.target, label: e.label, type: "smoothstep", animated: isCrit, className: isCrit ? "critical-edge" : undefined, labelStyle: { fill: "#8b93ad", fontSize: 10, fontWeight: 600 }, labelBgStyle: { fill: "#141a2e" }, style: { stroke: isCrit ? "#ff6b6b" : "#4a5372", strokeWidth: isCrit ? 2.5 : 1.5, opacity: inFocus ? 1 : 0.12, }, }; }), [graph.edges, showCritical, focusSet] ); const onNodeClick = useCallback((_: unknown, node: Node) => setFocus(node.id), []); const onPaneClick = useCallback(() => setFocus(null), []); const runSearch = useCallback( (q: string) => { setQuery(q); if (!q.trim()) return; const hit = baseNodes.find((n) => n.data.name.toLowerCase().includes(q.toLowerCase())); if (hit) { setFocus(hit.id); rf.setCenter(hit.position.x + 100, hit.position.y + 32, { zoom: 1.2, duration: 500 }); } }, [baseNodes, rf] ); const exportPng = useCallback(async () => { const el = document.querySelector(".react-flow__viewport"); if (!el) return; const dataUrl = await toPng(el, { backgroundColor: "#0e1220", filter: (node) => !(node instanceof HTMLElement && node.classList?.contains("react-flow__controls")), }); const a = document.createElement("a"); a.href = dataUrl; a.download = "abhaengigkeiten.png"; a.click(); }, []); return (
{/* Toolbar */}
runSearch(e.target.value)} placeholder={labels.search} className="w-40 border-0 bg-transparent text-[13px] text-foreground outline-none placeholder:text-muted-foreground" />
); } export function DependencyGraphView(props: { graph: DependencyGraph; kindLabels: Record; labels: Record; }) { return ( ); }