kcg-ai-monitoring/frontend/src/flow/SystemFlowViewer.tsx
htlee 17215be29c feat: System Flow 뷰어 — 포커스 모드 + 엣지 겹침 완화
## 포커스 모드
- 노드 선택 시 해당 노드 + 1-hop 연결된 노드만 활성
- 나머지 노드는 opacity 0.18 + grayscale, 엣지는 0.08로 dim 처리
- 상단 중앙 배지로 포커스 상태 표시 + "전체 보기 ✕" 해제 버튼
- 캔버스 빈 공간(pane) 클릭 시 포커스 해제
- 선택 노드는 스케일 1.02 + glow 효과로 강조

## 엣지 겹침 완화
- COL_WIDTH 360 → 440, ROW_HEIGHT 130 → 170 (간격 확대)
- smoothstep pathOptions.offset을 source별로 20→34→48... 분산
- 같은 노드에서 나가는 N번째 엣지는 서로 다른 경로로 라우팅
- 선택/포커스된 엣지는 zIndex 1000으로 최상단 표시

## 시각 보강
- 포커스된 엣지는 stroke #f8fafc + strokeWidth 2.8 + animated
- 비활성 엣지 라벨은 opacity 0.3, 텍스트 #475569로 어둡게
- 선택 노드 body: box-shadow + glow + scale 1.02

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 05:47:51 +09:00

378 lines
12 KiB
TypeScript

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<string> | null,
): LayoutResult {
// 1. 그룹 키별로 노드 분류 (stage 모드는 정해진 순서, menu 모드는 정렬)
const STAGE_ORDER = [
'수집',
'캐시',
'파이프라인',
'분석',
'선단',
'출력',
'저장소',
'API',
'UI',
'의사결정',
'외부',
];
const groups: Record<string, FlowNode[]> = {};
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: (
<div
className="sf-node-body"
data-status={node.status}
data-trigger={node.trigger}
data-selected={isSelected}
data-dimmed={isDimmed}
style={{ ['--accent' as never]: accent }}
>
<div className="sf-node-stage">{node.stage} · {node.kind}</div>
<div className="sf-node-label">{node.label}</div>
<div className="sf-node-id">{node.id}</div>
</div>
),
node,
} as Record<string, unknown>;
});
});
// 3. 엣지 변환 — source별 offset으로 겹침 완화
const sourceCounter: Record<string, number> = {};
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<FilterState>({
search: '',
stage: '전체',
menu: '전체',
kinds: new Set(),
triggers: new Set(),
statuses: new Set(),
});
const [groupBy, setGroupBy] = useState<'stage' | 'menu'>('stage');
const [selectedNodeId, setSelectedNodeId] = useState<string | null>(null);
const [selectedEdgeId, setSelectedEdgeId] = useState<string | null>(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<Set<string> | null>(() => {
if (!selectedNodeId) return null;
const connected = new Set<string>([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 (
<div className="system-flow-shell">
<FilterBar
filter={filter}
onChange={setFilter}
groupBy={groupBy}
onGroupByChange={setGroupBy}
meta={manifest.meta}
/>
<NodeListSidebar
nodes={filteredNodes}
selectedNodeId={selectedNodeId}
groupBy={groupBy}
onSelectNode={handleSelectNode}
/>
<div className="sf-canvas">
{focusedNodeIds && selectedNode && (
<div className="sf-focus-badge">
<span>
<strong>{selectedNode.label}</strong> {focusedNodeIds.size - 1}
</span>
<button type="button" onClick={onPaneClick}> </button>
</div>
)}
<ReactFlow
nodes={rfNodes}
edges={rfEdges}
onNodeClick={onNodeClick}
onEdgeClick={onEdgeClick}
onPaneClick={onPaneClick}
fitView
fitViewOptions={{ padding: 0.2 }}
minZoom={0.1}
maxZoom={1.5}
proOptions={{ hideAttribution: true }}
>
<Background color="#1e293b" gap={24} size={1.2} />
<Controls showInteractive={false} />
<MiniMap
pannable
zoomable
nodeColor={(node) => {
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)"
/>
</ReactFlow>
</div>
<NodeDetailPanel
selectedNode={selectedNode}
selectedEdge={selectedEdge}
allNodes={manifest.nodes}
allEdges={manifest.edges}
onSelectNode={handleSelectNode}
/>
</div>
);
}