## 포커스 모드 - 노드 선택 시 해당 노드 + 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>
378 lines
12 KiB
TypeScript
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>
|
|
);
|
|
}
|