import { useCallback, useEffect, useMemo, useState } from 'react'; import { Background, Controls, MarkerType, MiniMap, ReactFlow, type Edge, type EdgeMouseHandler, type Node, type NodeMouseHandler, } from '@xyflow/react'; import { manifest, type FlowEdge, type FlowNode } from './manifest'; import { stageColor } from './components/nodeShapes'; import { NodeListSidebar } from './components/NodeListSidebar'; import { NodeDetailPanel } from './components/NodeDetailPanel'; import { FilterBar, type FilterState } from './components/FilterBar'; // ─── 검색 매칭 ────────────────────────────────── function matchesSearch(node: FlowNode, query: string): boolean { if (!query) return true; const q = query.replace(/\s+/g, '').toLowerCase(); const haystack = [ node.id, node.label, node.shortDescription, node.stage, node.menu ?? '', node.kind, node.file ?? '', node.symbol ?? '', ...(node.tags ?? []), ...(node.inputs ?? []), ...(node.outputs ?? []), node.notes ?? '', ] .join(' ') .replace(/\s+/g, '') .toLowerCase(); return haystack.includes(q); } // ─── 레이아웃 계산 (stage/menu 그리드) ────────────────── const COL_WIDTH = 440; const ROW_HEIGHT = 170; const COL_PADDING_X = 80; const COL_PADDING_Y = 80; interface LayoutResult { nodes: Node[]; edges: Edge[]; } function buildLayout( flowNodes: FlowNode[], flowEdges: FlowEdge[], groupBy: 'stage' | 'menu', selectedNodeId: string | null, selectedEdgeId: string | null, focusedNodeIds: Set | null, ): LayoutResult { // 1. 그룹 키별로 노드 분류 (stage 모드는 정해진 순서, menu 모드는 정렬) const STAGE_ORDER = [ '수집', '캐시', '파이프라인', '분석', '선단', '출력', '저장소', 'API', 'UI', '의사결정', '외부', ]; const groups: Record = {}; flowNodes.forEach((n) => { const key = (groupBy === 'menu' ? n.menu : n.stage) ?? '기타'; if (!groups[key]) groups[key] = []; groups[key].push(n); }); const groupKeys = groupBy === 'stage' ? STAGE_ORDER.filter((s) => groups[s] && groups[s].length > 0).concat( Object.keys(groups).filter((k) => !STAGE_ORDER.includes(k)), ) : Object.keys(groups).sort(); // 2. 노드 위치 계산 const positionedNodes: Node[] = []; groupKeys.forEach((key, colIdx) => { const list = groups[key]; list.forEach((node, rowIdx) => { const x = COL_PADDING_X + colIdx * COL_WIDTH; const y = COL_PADDING_Y + rowIdx * ROW_HEIGHT; const accent = stageColor(node.stage); const isSelected = selectedNodeId === node.id; const isDimmed = focusedNodeIds !== null && !focusedNodeIds.has(node.id); positionedNodes.push({ id: node.id, position: { x, y }, data: { label: node.label, node }, type: 'default', style: { padding: 0, background: 'transparent', border: 'none', width: 240, opacity: isDimmed ? 0.18 : 1, transition: 'opacity 0.2s', pointerEvents: isDimmed ? 'none' : 'auto', }, } as Node); const last = positionedNodes[positionedNodes.length - 1]; last.data = { label: (
{node.stage} · {node.kind}
{node.label}
{node.id}
), node, } as Record; }); }); // 3. 엣지 변환 — source별 offset으로 겹침 완화 const sourceCounter: Record = {}; const positionedEdges: Edge[] = flowEdges.map((edge) => { const isSelected = selectedEdgeId === edge.id; const color = edge.kind === 'feedback' ? '#fbbf24' : edge.kind === 'trigger' ? '#86efac' : '#94a3b8'; // 같은 source에서 나가는 N번째 엣지에 offset 증가 → 경로 분산 const idxFromSource = sourceCounter[edge.source] ?? 0; sourceCounter[edge.source] = idxFromSource + 1; // 20~80px 사이에서 분산 (노드 너비 240 기준) const pathOffset = 20 + idxFromSource * 14; // 포커스 모드: 선택 노드와 연결된 엣지만 불투명 const isFocused = focusedNodeIds === null || (focusedNodeIds.has(edge.source) && focusedNodeIds.has(edge.target)); const isHighlighted = isSelected || (selectedNodeId !== null && (edge.source === selectedNodeId || edge.target === selectedNodeId)); const strokeColor = isHighlighted ? '#f8fafc' : color; const opacity = isFocused ? 1 : 0.08; return { id: edge.id, source: edge.source, target: edge.target, label: edge.label, type: 'smoothstep', pathOptions: { offset: pathOffset, borderRadius: 14 }, animated: isHighlighted || (isFocused && edge.kind === 'feedback'), markerEnd: { type: MarkerType.ArrowClosed, color: strokeColor, width: 18, height: 18, }, style: { stroke: strokeColor, strokeWidth: isHighlighted ? 2.8 : 1.6, strokeDasharray: edge.kind === 'feedback' ? '6 4' : undefined, opacity, transition: 'opacity 0.2s, stroke 0.2s, stroke-width 0.2s', }, labelStyle: { fill: isFocused ? '#cbd5e1' : '#475569', fontSize: 10 }, labelBgStyle: { fill: '#0f172a', fillOpacity: isFocused ? 0.9 : 0.3 }, zIndex: isHighlighted ? 1000 : isFocused ? 10 : 0, }; }); return { nodes: positionedNodes, edges: positionedEdges }; } // ─── 메인 뷰어 ────────────────────────────────── export function SystemFlowViewer() { const [filter, setFilter] = useState({ search: '', stage: '전체', menu: '전체', kinds: new Set(), triggers: new Set(), statuses: new Set(), }); const [groupBy, setGroupBy] = useState<'stage' | 'menu'>('stage'); const [selectedNodeId, setSelectedNodeId] = useState(null); const [selectedEdgeId, setSelectedEdgeId] = useState(null); // URL hash deep link useEffect(() => { const hash = window.location.hash; const m = hash.match(/^#node=(.+)$/); if (m) { const id = decodeURIComponent(m[1]); if (manifest.nodes.find((n) => n.id === id)) { setSelectedNodeId(id); } } }, []); // 선택된 노드 변경 시 hash 업데이트 useEffect(() => { if (selectedNodeId) { window.history.replaceState(null, '', `#node=${selectedNodeId}`); } }, [selectedNodeId]); // 필터링된 노드 const filteredNodes = useMemo(() => { return manifest.nodes.filter((n) => { if (filter.stage !== '전체' && n.stage !== filter.stage) return false; if (filter.menu !== '전체' && n.menu !== filter.menu) return false; if (filter.kinds.size > 0 && !filter.kinds.has(n.kind)) return false; if (filter.triggers.size > 0 && !filter.triggers.has(n.trigger)) return false; if (filter.statuses.size > 0 && !filter.statuses.has(n.status)) return false; if (!matchesSearch(n, filter.search)) return false; return true; }); }, [filter]); // 필터링된 노드 ID 집합 const filteredNodeIds = useMemo( () => new Set(filteredNodes.map((n) => n.id)), [filteredNodes], ); // 필터링된 엣지 (양 끝이 필터된 노드일 때만) const filteredEdges = useMemo(() => { return manifest.edges.filter( (e) => filteredNodeIds.has(e.source) && filteredNodeIds.has(e.target), ); }, [filteredNodeIds]); // 포커스 모드: 선택된 노드가 있으면 1-hop 연결 노드 집합 계산 // 선택이 없거나 엣지가 선택되면 null (전체 활성) const focusedNodeIds = useMemo | null>(() => { if (!selectedNodeId) return null; const connected = new Set([selectedNodeId]); filteredEdges.forEach((e) => { if (e.source === selectedNodeId) connected.add(e.target); if (e.target === selectedNodeId) connected.add(e.source); }); return connected; }, [selectedNodeId, filteredEdges]); // React Flow 노드/엣지 const { nodes: rfNodes, edges: rfEdges } = useMemo( () => buildLayout( filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId, focusedNodeIds, ), [filteredNodes, filteredEdges, groupBy, selectedNodeId, selectedEdgeId, focusedNodeIds], ); const selectedNode = useMemo( () => manifest.nodes.find((n) => n.id === selectedNodeId) ?? null, [selectedNodeId], ); const selectedEdge = useMemo( () => manifest.edges.find((e) => e.id === selectedEdgeId) ?? null, [selectedEdgeId], ); const onNodeClick: NodeMouseHandler = useCallback((_e, node) => { setSelectedNodeId(node.id); setSelectedEdgeId(null); }, []); const onEdgeClick: EdgeMouseHandler = useCallback((_e, edge) => { setSelectedEdgeId(edge.id); setSelectedNodeId(null); }, []); const onPaneClick = useCallback(() => { setSelectedNodeId(null); setSelectedEdgeId(null); }, []); const handleSelectNode = useCallback((id: string) => { setSelectedNodeId(id); setSelectedEdgeId(null); }, []); return (
{focusedNodeIds && selectedNode && (
{selectedNode.label} 연결 노드 {focusedNodeIds.size - 1}개만 표시 중
)} { const meta = manifest.nodes.find((n) => n.id === node.id); return meta ? `${stageColor(meta.stage)}aa` : '#334155'; }} nodeStrokeColor={(node) => { const meta = manifest.nodes.find((n) => n.id === node.id); return meta ? stageColor(meta.stage) : '#94a3b8'; }} maskColor="rgba(7, 17, 31, 0.7)" />
); }