Skip to content

Commit 41a98d1

Browse files
committed
feat: 添加画布缩放平移、输入校验、重置按钮等功能
- 画布支持鼠标滚轮缩放和拖拽平移,双击重置视图 - 优化随机生成算法,确保图连通性 - 增强输入校验,检查格式、范围、重复边等 - 添加重置按钮和 R 键快捷键 - 固定控制面板按钮位置,防止被文字挤压 - 更新随机生成按钮文案
1 parent 53e8f6e commit 41a98d1

File tree

7 files changed

+167
-35
lines changed

7 files changed

+167
-35
lines changed

src/components/App.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ function App() {
7575
onPrevious: goToPrevious,
7676
onNext: goToNext,
7777
onTogglePlay: toggle,
78+
onReset: () => goToStep(0),
7879
});
7980

8081
// Current step data
@@ -149,6 +150,7 @@ function App() {
149150
onPause={pause}
150151
onPrevious={goToPrevious}
151152
onNext={goToNext}
153+
onReset={() => goToStep(0)}
152154
/>
153155

154156
<ProgressBar

src/components/ControlPanel.css

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
font-size: 14px;
2222
cursor: pointer;
2323
transition: background-color 0.2s;
24+
flex-shrink: 0;
2425
}
2526

2627
.control-button:hover:not(:disabled) {
@@ -40,8 +41,9 @@
4041
.step-indicator {
4142
color: #d4d4d4;
4243
font-size: 14px;
43-
min-width: 100px;
44+
width: 100px;
4445
text-align: center;
46+
flex-shrink: 0;
4547
}
4648

4749
.step-current {
@@ -58,9 +60,10 @@
5860
.step-description {
5961
color: #858585;
6062
font-size: 13px;
61-
max-width: 400px;
62-
text-align: center;
63+
width: 300px;
64+
text-align: left;
6365
overflow: hidden;
6466
text-overflow: ellipsis;
6567
white-space: nowrap;
68+
flex-shrink: 0;
6669
}

src/components/ControlPanel.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ interface ControlPanelProps {
1111
onPause: () => void;
1212
onPrevious: () => void;
1313
onNext: () => void;
14+
onReset: () => void;
1415
}
1516

1617
export default function ControlPanel({
@@ -24,9 +25,19 @@ export default function ControlPanel({
2425
onPause,
2526
onPrevious,
2627
onNext,
28+
onReset,
2729
}: ControlPanelProps) {
2830
return (
2931
<div className="control-panel">
32+
<button
33+
className="control-button reset-button"
34+
onClick={onReset}
35+
title="重置 (R)"
36+
>
37+
⟲ 重置
38+
<span className="keyboard-hint">[R]</span>
39+
</button>
40+
3041
<button
3142
className="control-button"
3243
onClick={onPrevious}

src/components/GraphView.css

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,11 @@
1313
position: absolute;
1414
top: 0;
1515
left: 0;
16+
cursor: grab;
17+
}
18+
19+
.graph-svg:active {
20+
cursor: grabbing;
1621
}
1722

1823
/* Overlay container */
@@ -341,3 +346,15 @@
341346
.link-group.removed {
342347
display: none;
343348
}
349+
350+
/* Bottom right controls hint */
351+
.overlay-panel.bottom-right {
352+
bottom: 16px;
353+
right: 16px;
354+
}
355+
356+
.controls-hint {
357+
font-size: 11px;
358+
color: #6c6c6c;
359+
padding: 8px 12px;
360+
}

src/components/GraphView.tsx

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -103,10 +103,24 @@ export default function GraphView({
103103
.attr('d', 'M 0,-5 L 10,0 L 0,5')
104104
.attr('fill', 'rgba(108, 108, 108, 0.3)');
105105

106-
const g = svg.append('g');
106+
const g = svg.append('g').attr('class', 'canvas-group');
107107
const linkGroup = g.append('g').attr('class', 'links');
108108
const nodeGroup = g.append('g').attr('class', 'nodes');
109109

110+
// Add zoom and pan behavior
111+
const zoom = d3.zoom<SVGSVGElement, unknown>()
112+
.scaleExtent([0.3, 3]) // Min 30%, Max 300% zoom
113+
.on('zoom', (event) => {
114+
g.attr('transform', event.transform);
115+
});
116+
117+
svg.call(zoom);
118+
119+
// Double-click to reset view
120+
svg.on('dblclick.zoom', () => {
121+
svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity);
122+
});
123+
110124
const simulation = d3
111125
.forceSimulation<SimNode>(simData.simNodes)
112126
.force(
@@ -410,6 +424,11 @@ export default function GraphView({
410424
</div>
411425
</div>
412426

427+
{/* Bottom right - Controls hint */}
428+
<div className="overlay-panel bottom-right controls-hint">
429+
<div>🖱️ 滚轮缩放 | 拖拽平移 | 双击重置</div>
430+
</div>
431+
413432
{/* Bottom - Queue */}
414433
<div className="overlay-panel bottom-left">
415434
<div className="panel-title">📥 BFS队列 (先进先出)</div>

src/components/InputPanel.tsx

Lines changed: 103 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -20,33 +20,80 @@ export default function InputPanel({
2020

2121
const handleSubmit = () => {
2222
try {
23-
const num = parseInt(numCourses, 10);
24-
if (isNaN(num) || num < 1 || num > 20) {
23+
// 校验课程数量
24+
const trimmedNum = numCourses.trim();
25+
if (!trimmedNum) {
26+
setError('请输入课程数量');
27+
return;
28+
}
29+
const num = parseInt(trimmedNum, 10);
30+
if (isNaN(num) || !Number.isInteger(num)) {
31+
setError('课程数量必须是整数');
32+
return;
33+
}
34+
if (num < 1 || num > 20) {
2535
setError('课程数量必须是 1-20 之间的整数');
2636
return;
2737
}
2838

29-
const prereqs = JSON.parse(prerequisites);
39+
// 校验先修课程格式
40+
const trimmedPrereqs = prerequisites.trim();
41+
if (!trimmedPrereqs) {
42+
setError('请输入先修课程数组');
43+
return;
44+
}
45+
46+
let prereqs: unknown;
47+
try {
48+
prereqs = JSON.parse(trimmedPrereqs);
49+
} catch {
50+
setError('先修课程格式错误,请使用 JSON 数组格式,如 [[1,0],[2,1]]');
51+
return;
52+
}
53+
3054
if (!Array.isArray(prereqs)) {
31-
setError('先修课程必须是数组格式');
55+
setError('先修课程必须是数组格式,如 [[1,0],[2,1]]');
3256
return;
3357
}
3458

35-
for (const pair of prereqs) {
59+
// 校验每个先修关系
60+
const edgeSet = new Set<string>();
61+
for (let i = 0; i < prereqs.length; i++) {
62+
const pair = prereqs[i];
3663
if (!Array.isArray(pair) || pair.length !== 2) {
37-
setError('每个先修关系必须是 [a, b] 格式');
64+
setError(`第 ${i + 1} 个先修关系格式错误,必须是 [a, b] 格式`);
65+
return;
66+
}
67+
const [a, b] = pair;
68+
if (typeof a !== 'number' || typeof b !== 'number' || !Number.isInteger(a) || !Number.isInteger(b)) {
69+
setError(`第 ${i + 1} 个先修关系中的课程编号必须是整数`);
70+
return;
71+
}
72+
if (a < 0 || a >= num) {
73+
setError(`第 ${i + 1} 个先修关系中的课程 ${a} 超出范围 (0-${num - 1})`);
74+
return;
75+
}
76+
if (b < 0 || b >= num) {
77+
setError(`第 ${i + 1} 个先修关系中的课程 ${b} 超出范围 (0-${num - 1})`);
3878
return;
3979
}
40-
if (pair[0] < 0 || pair[0] >= num || pair[1] < 0 || pair[1] >= num) {
41-
setError(`课程编号必须在 0 到 ${num - 1} 之间`);
80+
if (a === b) {
81+
setError(`${i + 1} 个先修关系中课程不能依赖自己`);
4282
return;
4383
}
84+
// 检查重复边
85+
const edgeKey = `${a}-${b}`;
86+
if (edgeSet.has(edgeKey)) {
87+
setError(`存在重复的先修关系 [${a}, ${b}]`);
88+
return;
89+
}
90+
edgeSet.add(edgeKey);
4491
}
4592

4693
setError(null);
47-
onSubmit(num, prereqs);
94+
onSubmit(num, prereqs as number[][]);
4895
} catch {
49-
setError('先修课程格式错误,请使用 JSON 数组格式');
96+
setError('输入数据格式错误,请检查后重试');
5097
}
5198
};
5299

@@ -57,29 +104,55 @@ export default function InputPanel({
57104
setError('请先输入有效的课程数量 (2-20)');
58105
return;
59106
}
60-
61-
// 生成随机 DAG(有向无环图)
62-
// 边数量:节点数的 1-2 倍,确保图有一定复杂度
63-
const prereqs: number[][] = [];
64-
const maxEdges = Math.min(num * 2, (num * (num - 1)) / 2);
65-
const edgeCount = Math.floor(Math.random() * maxEdges) + Math.max(1, Math.floor(num / 2));
107+
108+
// 生成连通的随机 DAG(有向无环图)
109+
// 策略:先生成一条主链保证基本连通,再随机添加额外边
110+
const edges: number[][] = [];
66111
const existingEdges = new Set<string>();
67-
68-
for (let i = 0; i < edgeCount && prereqs.length < maxEdges; i++) {
69-
// 确保 from > to 来避免环(拓扑序:小编号是先修课)
70-
const to = Math.floor(Math.random() * (num - 1));
71-
const from = Math.floor(Math.random() * (num - to - 1)) + to + 1;
112+
113+
// 随机打乱节点顺序,作为拓扑序
114+
const topoOrder = Array.from({ length: num }, (_, i) => i);
115+
for (let i = topoOrder.length - 1; i > 0; i--) {
116+
const j = Math.floor(Math.random() * (i + 1));
117+
[topoOrder[i], topoOrder[j]] = [topoOrder[j], topoOrder[i]];
118+
}
119+
120+
// 第一步:生成主链,确保图基本连通
121+
// 每个节点(除了第一个)至少有一条来自前面节点的边
122+
for (let i = 1; i < num; i++) {
123+
// 从前面的节点中随机选一个作为前置
124+
const prevIdx = Math.floor(Math.random() * i);
125+
const from = topoOrder[i]; // 后面的节点
126+
const to = topoOrder[prevIdx]; // 前面的节点作为前置
127+
const key = `${from}-${to}`;
128+
existingEdges.add(key);
129+
edges.push([from, to]);
130+
}
131+
132+
// 第二步:随机添加额外边增加复杂度
133+
// 额外边数量:节点数的 50%-100%
134+
const extraEdgeCount = Math.floor(Math.random() * (num / 2)) + Math.floor(num / 2);
135+
let attempts = 0;
136+
const maxAttempts = extraEdgeCount * 3;
137+
138+
while (edges.length < num - 1 + extraEdgeCount && attempts < maxAttempts) {
139+
attempts++;
140+
// 随机选两个不同位置的节点,确保拓扑序正确(后面指向前面)
141+
const idx1 = Math.floor(Math.random() * (num - 1)) + 1;
142+
const idx2 = Math.floor(Math.random() * idx1);
143+
const from = topoOrder[idx1];
144+
const to = topoOrder[idx2];
72145
const key = `${from}-${to}`;
73-
74-
if (!existingEdges.has(key) && from !== to) {
146+
147+
if (!existingEdges.has(key)) {
75148
existingEdges.add(key);
76-
prereqs.push([from, to]);
149+
edges.push([from, to]);
77150
}
78151
}
79-
80-
setPrerequisites(JSON.stringify(prereqs));
152+
153+
setPrerequisites(JSON.stringify(edges));
81154
setError(null);
82-
onSubmit(num, prereqs);
155+
onSubmit(num, edges);
83156
};
84157

85158
return (
@@ -104,12 +177,12 @@ export default function InputPanel({
104177
/>
105178
</div>
106179
{error && <div className="input-error">{error}</div>}
180+
<button className="random-button" onClick={generateRandom}>
181+
🎲 随机生成指定节点个数的有向无环图
182+
</button>
107183
<button className="run-button" onClick={handleSubmit}>
108184
运行
109185
</button>
110-
<button className="random-button" onClick={generateRandom}>
111-
🎲 随机生成
112-
</button>
113186
</div>
114187
);
115188
}

src/hooks/useKeyboardShortcuts.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,15 @@ interface UseKeyboardShortcutsOptions {
44
onPrevious: () => void;
55
onNext: () => void;
66
onTogglePlay: () => void;
7+
onReset?: () => void;
78
enabled?: boolean;
89
}
910

1011
export function useKeyboardShortcuts({
1112
onPrevious,
1213
onNext,
1314
onTogglePlay,
15+
onReset,
1416
enabled = true,
1517
}: UseKeyboardShortcutsOptions): void {
1618
const handleKeyDown = useCallback(
@@ -38,9 +40,14 @@ export function useKeyboardShortcuts({
3840
event.preventDefault();
3941
onTogglePlay();
4042
break;
43+
case 'r':
44+
case 'R':
45+
event.preventDefault();
46+
onReset?.();
47+
break;
4148
}
4249
},
43-
[enabled, onPrevious, onNext, onTogglePlay]
50+
[enabled, onPrevious, onNext, onTogglePlay, onReset]
4451
);
4552

4653
useEffect(() => {

0 commit comments

Comments
 (0)