Skip to content

Commit 77f6dd9

Browse files
committed
feat: 实现 Canvas 可视化组件
- 创建 Canvas 基础组件,支持 D3.js 缩放和拖动 - 实现 StackVisualizer 组件,绘制主栈和辅助栈 - 实现 AnimationController 动画控制器 - 实现 AnnotationLayer 标注系统 - 支持 push/pop/top/getMin 操作的可视化 - 显示步骤信息和变量状态
1 parent 0a981fe commit 77f6dd9

File tree

8 files changed

+1003
-36
lines changed

8 files changed

+1003
-36
lines changed

.kiro/specs/min-stack-visualizer/tasks.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,24 +117,24 @@
117117
- 变量值显示在代码行后
118118
- _Requirements: 5.7, 5.8, 5.9, 5.10_
119119

120-
- [ ] 11. UI 组件开发 - Canvas
121-
- [ ] 11.1 实现 Canvas 基础组件
120+
- [x] 11. UI 组件开发 - Canvas
121+
- [x] 11.1 实现 Canvas 基础组件
122122
- 创建 components/Canvas/Canvas.tsx 和样式文件
123123
- 使用 D3.js 创建 SVG 画布
124124
- 实现拖动和缩放功能
125125
- _Requirements: 6.1, 6.2, 6.3_
126-
- [ ] 11.2 实现栈可视化
126+
- [x] 11.2 实现栈可视化
127127
- 创建 components/Canvas/StackVisualizer.tsx
128128
- 绘制主栈和辅助栈
129129
- 显示栈元素数值
130130
- _Requirements: 6.4, 6.5_
131-
- [ ] 11.3 实现动画效果
131+
- [x] 11.3 实现动画效果
132132
- 创建 components/Canvas/AnimationController.ts
133133
- push 入栈动画
134134
- pop 出栈动画
135135
- 高亮动画(top、getMin)
136136
- _Requirements: 6.6, 6.7, 6.8, 6.9_
137-
- [ ] 11.4 实现标注系统
137+
- [x] 11.4 实现标注系统
138138
- 数据流箭头和文字标注
139139
- 状态变更说明
140140
- _Requirements: 6.10, 6.11, 6.12, 6.13_

src/App.tsx

Lines changed: 10 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import { Header } from './components/Header';
33
import { AlgorithmModal } from './components/AlgorithmModal';
44
import { InputPanel } from './components/InputPanel';
55
import { CodePanel } from './components/CodePanel/CodePanel';
6+
import { Canvas } from './components/Canvas';
67
import type { Operation, Language, AlgorithmStep } from './types';
78
import { generateSteps } from './services/stepGenerator';
89
import { getLanguagePreference, saveLanguagePreference } from './services/storageService';
@@ -15,6 +16,7 @@ function App() {
1516
const [currentStep, setCurrentStep] = useState(0);
1617
const [language, setLanguage] = useState<Language>('python');
1718
const [isPlaying, setIsPlaying] = useState(false);
19+
const [isAnimating, setIsAnimating] = useState(false);
1820

1921
// 加载用户语言偏好
2022
useEffect(() => {
@@ -57,11 +59,15 @@ function App() {
5759

5860
// 播放控制
5961
const handlePrevStep = useCallback(() => {
62+
setIsAnimating(true);
6063
setCurrentStep((prev) => Math.max(0, prev - 1));
64+
setTimeout(() => setIsAnimating(false), 300);
6165
}, []);
6266

6367
const handleNextStep = useCallback(() => {
68+
setIsAnimating(true);
6469
setCurrentStep((prev) => Math.min(steps.length - 1, prev + 1));
70+
setTimeout(() => setIsAnimating(false), 300);
6571
}, [steps.length]);
6672

6773
const handlePlayPause = useCallback(() => {
@@ -129,37 +135,10 @@ function App() {
129135

130136
<div className="visualization-area">
131137
<div className="canvas-container">
132-
{/* Canvas 组件将在后续任务中实现 */}
133-
<div className="canvas-placeholder">
134-
{steps.length > 0 ? (
135-
<div className="step-info">
136-
<div className="step-counter">
137-
步骤 {currentStep + 1} / {steps.length}
138-
</div>
139-
<div className="step-description">
140-
{steps[currentStep]?.description}
141-
</div>
142-
<div className="stack-display">
143-
<div className="stack-item">
144-
<span className="stack-label">主栈:</span>
145-
<span className="stack-value">
146-
[{steps[currentStep]?.mainStack.join(', ')}]
147-
</span>
148-
</div>
149-
<div className="stack-item">
150-
<span className="stack-label">辅助栈:</span>
151-
<span className="stack-value">
152-
[{steps[currentStep]?.minStack.join(', ')}]
153-
</span>
154-
</div>
155-
</div>
156-
</div>
157-
) : (
158-
<div className="placeholder-text">
159-
请输入操作序列或选择预设样例开始演示
160-
</div>
161-
)}
162-
</div>
138+
<Canvas
139+
step={steps.length > 0 ? steps[currentStep] : null}
140+
isAnimating={isAnimating}
141+
/>
163142
</div>
164143

165144
<div className="code-container">
Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
1+
import * as d3 from 'd3';
2+
import type { AnimationState } from '../../types';
3+
4+
export interface AnimationConfig {
5+
duration: number;
6+
easing: (t: number) => number;
7+
}
8+
9+
const DEFAULT_CONFIG: AnimationConfig = {
10+
duration: 300,
11+
easing: d3.easeCubicOut,
12+
};
13+
14+
/**
15+
* 动画控制器 - 管理栈可视化的动画效果
16+
*/
17+
export class AnimationController {
18+
private config: AnimationConfig;
19+
private activeAnimations: Map<string, d3.Selection<SVGElement, unknown, null, undefined>> = new Map();
20+
21+
constructor(config: Partial<AnimationConfig> = {}) {
22+
this.config = { ...DEFAULT_CONFIG, ...config };
23+
}
24+
25+
/**
26+
* 执行入栈动画
27+
*/
28+
animatePush(
29+
element: SVGGElement,
30+
targetY: number,
31+
onComplete?: () => void
32+
): void {
33+
const selection = d3.select(element);
34+
35+
// 从上方滑入
36+
selection
37+
.attr('opacity', 0)
38+
.attr('transform', `translate(0, ${targetY - 60})`)
39+
.transition()
40+
.duration(this.config.duration)
41+
.ease(this.config.easing)
42+
.attr('opacity', 1)
43+
.attr('transform', `translate(0, ${targetY})`)
44+
.on('end', () => onComplete?.());
45+
}
46+
47+
/**
48+
* 执行出栈动画
49+
*/
50+
animatePop(
51+
element: SVGGElement,
52+
onComplete?: () => void
53+
): void {
54+
const selection = d3.select(element);
55+
const currentTransform = selection.attr('transform');
56+
const match = currentTransform?.match(/translate\(([^,]+),\s*([^)]+)\)/);
57+
const currentY = match ? parseFloat(match[2]) : 0;
58+
59+
selection
60+
.transition()
61+
.duration(this.config.duration)
62+
.ease(this.config.easing)
63+
.attr('opacity', 0)
64+
.attr('transform', `translate(0, ${currentY - 60})`)
65+
.on('end', () => onComplete?.());
66+
}
67+
68+
/**
69+
* 执行高亮动画
70+
*/
71+
animateHighlight(
72+
element: SVGGElement,
73+
color: string,
74+
onComplete?: () => void
75+
): void {
76+
const selection = d3.select(element);
77+
const rect = selection.select('rect');
78+
79+
// 脉冲效果
80+
rect
81+
.transition()
82+
.duration(150)
83+
.attr('stroke-width', 4)
84+
.attr('stroke', color)
85+
.transition()
86+
.duration(150)
87+
.attr('stroke-width', 2)
88+
.transition()
89+
.duration(150)
90+
.attr('stroke-width', 4)
91+
.transition()
92+
.duration(150)
93+
.attr('stroke-width', 2)
94+
.on('end', () => onComplete?.());
95+
}
96+
97+
/**
98+
* 执行比较动画 - 显示两个元素之间的比较
99+
*/
100+
animateCompare(
101+
element1: SVGGElement,
102+
element2: SVGGElement,
103+
onComplete?: () => void
104+
): void {
105+
const sel1 = d3.select(element1);
106+
const sel2 = d3.select(element2);
107+
108+
// 同时高亮两个元素
109+
[sel1, sel2].forEach((sel) => {
110+
sel.select('rect')
111+
.transition()
112+
.duration(200)
113+
.attr('stroke', '#f0883e')
114+
.attr('stroke-width', 3)
115+
.transition()
116+
.duration(200)
117+
.attr('stroke-width', 2);
118+
});
119+
120+
setTimeout(() => onComplete?.(), 400);
121+
}
122+
123+
/**
124+
* 执行数据流动画 - 显示值从一个位置流向另一个位置
125+
*/
126+
animateDataFlow(
127+
svg: SVGSVGElement,
128+
fromX: number,
129+
fromY: number,
130+
toX: number,
131+
toY: number,
132+
value: string,
133+
onComplete?: () => void
134+
): void {
135+
const group = d3.select(svg).append('g').attr('class', 'data-flow-animation');
136+
137+
// 创建流动的圆点
138+
const circle = group.append('circle')
139+
.attr('cx', fromX)
140+
.attr('cy', fromY)
141+
.attr('r', 15)
142+
.attr('fill', '#7ee787')
143+
.attr('opacity', 0.9);
144+
145+
// 创建值文本
146+
const text = group.append('text')
147+
.attr('x', fromX)
148+
.attr('y', fromY)
149+
.attr('text-anchor', 'middle')
150+
.attr('dominant-baseline', 'central')
151+
.attr('fill', '#0d1117')
152+
.attr('font-size', 12)
153+
.attr('font-weight', 600)
154+
.text(value);
155+
156+
// 动画移动
157+
circle.transition()
158+
.duration(this.config.duration * 1.5)
159+
.ease(d3.easeCubicInOut)
160+
.attr('cx', toX)
161+
.attr('cy', toY);
162+
163+
text.transition()
164+
.duration(this.config.duration * 1.5)
165+
.ease(d3.easeCubicInOut)
166+
.attr('x', toX)
167+
.attr('y', toY)
168+
.on('end', () => {
169+
group.remove();
170+
onComplete?.();
171+
});
172+
}
173+
174+
/**
175+
* 停止所有动画
176+
*/
177+
stopAll(): void {
178+
this.activeAnimations.forEach((selection) => {
179+
selection.interrupt();
180+
});
181+
this.activeAnimations.clear();
182+
}
183+
184+
/**
185+
* 设置动画配置
186+
*/
187+
setConfig(config: Partial<AnimationConfig>): void {
188+
this.config = { ...this.config, ...config };
189+
}
190+
191+
/**
192+
* 根据动画状态执行相应动画
193+
*/
194+
executeAnimation(
195+
animationState: AnimationState,
196+
elements: {
197+
mainStackElements: SVGGElement[];
198+
minStackElements: SVGGElement[];
199+
svg: SVGSVGElement;
200+
},
201+
onComplete?: () => void
202+
): void {
203+
const { mainStackElements, minStackElements } = elements;
204+
205+
switch (animationState.type) {
206+
case 'push':
207+
if (animationState.targetStack === 'main' && mainStackElements.length > 0) {
208+
const lastElement = mainStackElements[mainStackElements.length - 1];
209+
this.animatePush(lastElement, 0, onComplete);
210+
}
211+
break;
212+
213+
case 'pop':
214+
if (animationState.targetStack === 'main' && mainStackElements.length > 0) {
215+
const lastElement = mainStackElements[mainStackElements.length - 1];
216+
this.animatePop(lastElement, onComplete);
217+
}
218+
break;
219+
220+
case 'highlight-top':
221+
if (mainStackElements.length > 0) {
222+
const topElement = mainStackElements[mainStackElements.length - 1];
223+
this.animateHighlight(topElement, '#f0883e', onComplete);
224+
}
225+
break;
226+
227+
case 'highlight-min':
228+
if (minStackElements.length > 0) {
229+
const topElement = minStackElements[minStackElements.length - 1];
230+
this.animateHighlight(topElement, '#f0883e', onComplete);
231+
}
232+
break;
233+
234+
case 'compare':
235+
if (mainStackElements.length > 0 && minStackElements.length > 0) {
236+
const mainTop = mainStackElements[mainStackElements.length - 1];
237+
const minTop = minStackElements[minStackElements.length - 1];
238+
this.animateCompare(mainTop, minTop, onComplete);
239+
}
240+
break;
241+
242+
default:
243+
onComplete?.();
244+
}
245+
}
246+
}
247+
248+
// 导出单例实例
249+
export const animationController = new AnimationController();

0 commit comments

Comments
 (0)