|
1 | | -import React, { useEffect, useRef } from "react"; |
2 | | -import * as d3 from "d3"; |
| 1 | +import React, { useEffect, useMemo, useRef } from 'react'; |
| 2 | +import * as d3 from 'd3'; |
3 | 3 |
|
4 | 4 | const GraphAnimation = ({ data, currentStep }) => { |
5 | 5 | const svgRef = useRef(null); |
| 6 | + const frames = data?.frames || []; |
| 7 | + const frame = frames[currentStep] || null; |
| 8 | + const previousFrame = frames[currentStep - 1] || null; |
| 9 | + const vertexCount = useMemo( |
| 10 | + () => frame?.vertexCount ?? previousFrame?.vertexCount ?? 0, |
| 11 | + [frame, previousFrame] |
| 12 | + ); |
6 | 13 |
|
7 | | - const getVariableStateAt = (index, steps, variables) => { |
8 | | - const vars = {}; |
9 | | - variables?.forEach(v => vars[v.name] = v.initialValue); |
10 | | - |
11 | | - for (let i = 0; i <= index; i++) { |
12 | | - const changes = steps[i]?.changes; |
13 | | - if (changes) { |
14 | | - for (const change of changes) { |
15 | | - vars[change.variable] = change.after; |
16 | | - } |
17 | | - } |
18 | | - } |
19 | | - return vars; |
20 | | - }; |
| 14 | + const edges = useMemo( |
| 15 | + () => frame?.edges ?? previousFrame?.edges ?? [], |
| 16 | + [frame, previousFrame] |
| 17 | + ); |
| 18 | + |
| 19 | + const adjacency = useMemo( |
| 20 | + () => frame?.adjacency ?? previousFrame?.adjacency ?? [], |
| 21 | + [frame, previousFrame] |
| 22 | + ); |
| 23 | + |
| 24 | + const highlightedEdge = useMemo( |
| 25 | + () => frame?.highlight?.edge ?? previousFrame?.highlight?.edge ?? null, |
| 26 | + [frame, previousFrame] |
| 27 | + ); |
21 | 28 |
|
22 | | - const drawGraph = (nodes, edges) => { |
| 29 | + useEffect(() => { |
23 | 30 | const svg = d3.select(svgRef.current); |
24 | | - svg.selectAll("*").remove(); |
| 31 | + svg.selectAll('*').remove(); |
| 32 | + |
| 33 | + if (!frame && !previousFrame) { |
| 34 | + return; |
| 35 | + } |
25 | 36 |
|
26 | 37 | const width = 600; |
27 | 38 | const height = 280; |
28 | 39 |
|
29 | | - const nodeObjs = nodes.map(id => ({ id })); |
30 | | - const nodeMap = Object.fromEntries(nodeObjs.map(n => [n.id, n])); |
31 | | - |
32 | | - const linkObjs = edges.map(([source, target]) => ({ |
33 | | - source: nodeMap[source], |
34 | | - target: nodeMap[target] |
| 40 | + const nodes = Array.from({ length: vertexCount }, (_, id) => ({ id })); |
| 41 | + const links = edges.map(([source, target]) => ({ |
| 42 | + source, |
| 43 | + target, |
| 44 | + highlighted: highlightedEdge && ( |
| 45 | + (highlightedEdge[0] === source && highlightedEdge[1] === target) || |
| 46 | + (highlightedEdge[0] === target && highlightedEdge[1] === source) |
| 47 | + ) |
35 | 48 | })); |
36 | 49 |
|
37 | | - const simulation = d3.forceSimulation(nodeObjs) |
38 | | - .force("link", d3.forceLink(linkObjs).id(d => d.id).distance(100)) |
39 | | - .force("charge", d3.forceManyBody().strength(-300)) |
40 | | - .force("center", d3.forceCenter(width / 2, height / 2)); |
| 50 | + const simulation = d3.forceSimulation(nodes) |
| 51 | + .force('charge', d3.forceManyBody().strength(-400)) |
| 52 | + .force('center', d3.forceCenter(width / 2, height / 2)) |
| 53 | + .force('link', d3.forceLink(links).id(d => d.id).distance(120)) |
| 54 | + .force('collision', d3.forceCollide().radius(40)); |
41 | 55 |
|
42 | | - const g = svg.append("g"); |
| 56 | + const g = svg.append('g'); |
43 | 57 |
|
44 | | - const link = g.selectAll("line") |
45 | | - .data(linkObjs) |
| 58 | + const link = g.selectAll('line') |
| 59 | + .data(links) |
46 | 60 | .enter() |
47 | | - .append("line") |
48 | | - .attr("stroke", "#aaa") |
49 | | - .attr("stroke-width", 2); |
| 61 | + .append('line') |
| 62 | + .attr('stroke', d => d.highlighted ? '#f97316' : '#94a3b8') |
| 63 | + .attr('stroke-width', d => d.highlighted ? 4 : 2); |
50 | 64 |
|
51 | | - const node = g.selectAll("circle") |
52 | | - .data(nodeObjs) |
| 65 | + const node = g.selectAll('circle') |
| 66 | + .data(nodes) |
53 | 67 | .enter() |
54 | | - .append("circle") |
55 | | - .attr("r", 20) |
56 | | - .attr("fill", "#90caf9"); |
57 | | - |
58 | | - const label = g.selectAll("text") |
59 | | - .data(nodeObjs) |
| 68 | + .append('circle') |
| 69 | + .attr('r', 22) |
| 70 | + .attr('fill', '#60a5fa') |
| 71 | + .attr('stroke', '#0f172a') |
| 72 | + .attr('stroke-width', 1.5); |
| 73 | + |
| 74 | + const label = g.selectAll('text') |
| 75 | + .data(nodes) |
60 | 76 | .enter() |
61 | | - .append("text") |
62 | | - .text(d => d.id) |
63 | | - .attr("text-anchor", "middle") |
64 | | - .attr("dy", 5); |
65 | | - |
66 | | - simulation.on("tick", () => { |
| 77 | + .append('text') |
| 78 | + .attr('text-anchor', 'middle') |
| 79 | + .attr('font-size', 14) |
| 80 | + .attr('font-weight', '600') |
| 81 | + .attr('fill', '#0f172a') |
| 82 | + .text(d => d.id); |
| 83 | + |
| 84 | + simulation.on('tick', () => { |
67 | 85 | link |
68 | | - .attr("x1", d => d.source.x ?? 0) |
69 | | - .attr("y1", d => d.source.y ?? 0) |
70 | | - .attr("x2", d => d.target.x ?? 0) |
71 | | - .attr("y2", d => d.target.y ?? 0); |
| 86 | + .attr('x1', d => d.source.x ?? 0) |
| 87 | + .attr('y1', d => d.source.y ?? 0) |
| 88 | + .attr('x2', d => d.target.x ?? 0) |
| 89 | + .attr('y2', d => d.target.y ?? 0); |
72 | 90 |
|
73 | 91 | node |
74 | | - .attr("cx", d => d.x ?? 0) |
75 | | - .attr("cy", d => d.y ?? 0); |
| 92 | + .attr('cx', d => d.x ?? 0) |
| 93 | + .attr('cy', d => d.y ?? 0); |
76 | 94 |
|
77 | 95 | label |
78 | | - .attr("x", d => d.x ?? 0) |
79 | | - .attr("y", d => d.y ?? 0); |
| 96 | + .attr('x', d => d.x ?? 0) |
| 97 | + .attr('y', d => (d.y ?? 0) + 5); |
80 | 98 | }); |
81 | | - }; |
82 | | - |
83 | | - const drawStep = (stepIndex) => { |
84 | | - const step = data.steps?.[stepIndex]; |
85 | | - const structure = step?.dataStructure; |
86 | | - if (structure?.type === "graph") { |
87 | | - const nodes = structure.nodes; |
88 | | - const edges = structure.edges || []; |
89 | | - drawGraph(nodes, edges); |
90 | | - } |
91 | | - }; |
92 | 99 |
|
93 | | - useEffect(() => { |
94 | | - if (!data?.steps || !Array.isArray(data.steps)) return; |
95 | | - if (currentStep < 0 || currentStep >= data.steps.length) return; |
96 | | - drawStep(currentStep); |
97 | | - }, [currentStep, data]); |
| 100 | + return () => simulation.stop(); |
| 101 | + }, [frame, previousFrame, vertexCount, edges, highlightedEdge]); |
98 | 102 |
|
99 | | - const steps = data?.steps || []; |
100 | | - const variables = data?.variables || []; |
101 | | - const currentVariables = getVariableStateAt(currentStep, steps, variables); |
102 | | - const stepData = steps[currentStep]; |
| 103 | + const description = frame?.description || '그래프 상태를 추적합니다.'; |
103 | 104 |
|
104 | 105 | return ( |
105 | 106 | <div style={{ |
106 | 107 | width: '100%', |
107 | 108 | height: '100%', |
108 | | - maxHeight: '600px', |
| 109 | + maxHeight: '640px', |
109 | 110 | padding: '16px', |
110 | 111 | display: 'flex', |
111 | 112 | flexDirection: 'column', |
112 | 113 | gap: '16px', |
113 | 114 | fontFamily: '"Segoe UI", sans-serif', |
114 | 115 | overflow: 'auto' |
115 | 116 | }}> |
116 | | - {/* 현재 단계 설명 */} |
117 | | - {stepData?.description && ( |
118 | | - <div style={{ |
119 | | - padding: '12px', |
120 | | - background: '#f0f9ff', |
121 | | - borderRadius: '8px', |
122 | | - borderLeft: '4px solid #6a5acd', |
123 | | - fontSize: '14px', |
124 | | - color: '#1e293b', |
125 | | - fontStyle: 'italic' |
126 | | - }}> |
127 | | - <strong>Step {currentStep + 1}:</strong> {stepData.description} |
128 | | - </div> |
129 | | - )} |
| 117 | + <div style={{ |
| 118 | + padding: '12px', |
| 119 | + background: '#f8fafc', |
| 120 | + borderRadius: '8px', |
| 121 | + borderLeft: '4px solid #2563eb', |
| 122 | + fontSize: '14px', |
| 123 | + color: '#1e293b' |
| 124 | + }}> |
| 125 | + <strong>Step {currentStep + 1} / {frames.length}:</strong> {description} |
| 126 | + </div> |
130 | 127 |
|
131 | | - {/* 🎯 D3 SVG 시각화 영역 */} |
132 | 128 | <div style={{ |
133 | 129 | flex: 1, |
134 | 130 | display: 'flex', |
135 | 131 | alignItems: 'center', |
136 | 132 | justifyContent: 'center', |
137 | 133 | background: '#ffffff', |
138 | | - border: '1px solid #eee', |
| 134 | + border: '1px solid #e2e8f0', |
139 | 135 | borderRadius: '8px', |
140 | | - minHeight: '200px', |
141 | | - maxHeight: '400px', |
| 136 | + minHeight: '260px', |
142 | 137 | padding: '16px' |
143 | 138 | }}> |
144 | | - <svg ref={svgRef} |
145 | | - width="600" |
146 | | - height="280" |
147 | | - style={{background: '#f9f9f9', borderRadius: '8px', border: '1px solid #ddd'}}></svg> |
| 139 | + <svg |
| 140 | + ref={svgRef} |
| 141 | + width="600" |
| 142 | + height="280" |
| 143 | + style={{ background: '#f1f5f9', borderRadius: '8px' }} |
| 144 | + /> |
| 145 | + </div> |
| 146 | + |
| 147 | + <div style={{ |
| 148 | + display: 'grid', |
| 149 | + gridTemplateColumns: 'repeat(auto-fit, minmax(140px, 1fr))', |
| 150 | + gap: '10px' |
| 151 | + }}> |
| 152 | + <div style={{ |
| 153 | + padding: '10px', |
| 154 | + background: '#f8fafc', |
| 155 | + borderRadius: '8px', |
| 156 | + border: '1px solid #e2e8f0', |
| 157 | + textAlign: 'center', |
| 158 | + fontSize: '12px' |
| 159 | + }}> |
| 160 | + <div style={{ color: '#64748b', marginBottom: '4px' }}>정점 수</div> |
| 161 | + <div style={{ fontWeight: '700', color: '#0f172a' }}>{vertexCount}</div> |
| 162 | + </div> |
| 163 | + |
| 164 | + <div style={{ |
| 165 | + padding: '10px', |
| 166 | + background: '#f8fafc', |
| 167 | + borderRadius: '8px', |
| 168 | + border: '1px solid #e2e8f0', |
| 169 | + textAlign: 'center', |
| 170 | + fontSize: '12px' |
| 171 | + }}> |
| 172 | + <div style={{ color: '#64748b', marginBottom: '4px' }}>간선 수</div> |
| 173 | + <div style={{ fontWeight: '700', color: '#0f172a' }}>{edges.length}</div> |
| 174 | + </div> |
| 175 | + |
| 176 | + {highlightedEdge && ( |
| 177 | + <div style={{ |
| 178 | + padding: '10px', |
| 179 | + background: '#fff7ed', |
| 180 | + borderRadius: '8px', |
| 181 | + border: '1px solid #fdba74', |
| 182 | + textAlign: 'center', |
| 183 | + fontSize: '12px', |
| 184 | + color: '#c2410c' |
| 185 | + }}> |
| 186 | + <div style={{ marginBottom: '4px' }}>강조 간선</div> |
| 187 | + <div style={{ fontWeight: '700' }}>({highlightedEdge[0]}, {highlightedEdge[1]})</div> |
| 188 | + </div> |
| 189 | + )} |
| 190 | + </div> |
| 191 | + |
| 192 | + <div style={{ |
| 193 | + background: '#ffffff', |
| 194 | + borderRadius: '8px', |
| 195 | + border: '1px solid #e2e8f0', |
| 196 | + padding: '12px' |
| 197 | + }}> |
| 198 | + <div style={{ |
| 199 | + fontSize: '12px', |
| 200 | + color: '#475569', |
| 201 | + fontWeight: '600', |
| 202 | + marginBottom: '8px' |
| 203 | + }}> |
| 204 | + 인접 행렬 (현재 상태) |
| 205 | + </div> |
| 206 | + <div style={{ |
| 207 | + display: 'grid', |
| 208 | + gridTemplateColumns: `repeat(${vertexCount || 1}, minmax(24px, 1fr))`, |
| 209 | + gap: '6px' |
| 210 | + }}> |
| 211 | + {(adjacency || []).slice(0, vertexCount).map((row, rowIndex) => ( |
| 212 | + row.slice(0, vertexCount).map((cell, colIndex) => { |
| 213 | + const isHighlighted = highlightedEdge && ( |
| 214 | + (highlightedEdge[0] === rowIndex && highlightedEdge[1] === colIndex) || |
| 215 | + (highlightedEdge[1] === rowIndex && highlightedEdge[0] === colIndex) |
| 216 | + ); |
| 217 | + return ( |
| 218 | + <div |
| 219 | + key={`${rowIndex}-${colIndex}`} |
| 220 | + style={{ |
| 221 | + padding: '6px 4px', |
| 222 | + textAlign: 'center', |
| 223 | + background: isHighlighted ? '#fde68a' : '#f1f5f9', |
| 224 | + borderRadius: '4px', |
| 225 | + fontSize: '12px', |
| 226 | + fontWeight: '600', |
| 227 | + color: '#0f172a' |
| 228 | + }} |
| 229 | + > |
| 230 | + {cell} |
| 231 | + </div> |
| 232 | + ); |
| 233 | + }) |
| 234 | + ))} |
| 235 | + </div> |
148 | 236 | </div> |
149 | 237 | </div> |
150 | 238 | ); |
|
0 commit comments