Skip to content

Commit 1891eae

Browse files
committed
fix(canvas): 坐标计算纳入节点直径约束,消除叶子节点重叠遮挡
1 parent 4501056 commit 1891eae

4 files changed

Lines changed: 215 additions & 6 deletions

File tree

Lines changed: 201 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,201 @@
1+
# 画布叶子节点重叠遮挡修复 + 推送触发部署 Implementation Plan
2+
3+
> **For agentic workers:** REQUIRED SUB-SKILL: `superpowers:subagent-driven-development`
4+
> Steps use checkbox (`- [ ]`) syntax.
5+
6+
**Goal:** 修复画布中叶子节点圆圈互相重叠遮挡的问题(计算树位置时纳入节点直径约束),并把本地 7 个 commit push 到 main 触发 GitHub Actions 自动部署,验证线上为最新代码。
7+
8+
**Architecture:** 根因在 `buildVisualTree`(treeUtils.ts:59-128)——当前 `xUnit = usableWidth / totalWidth` 只保证节点坐标在画布内,未保证相邻节点间距 ≥ 节点直径。当叶子数 ≥ 15 时间距 < 48px 直径导致重叠。修复:引入 `nodeRadius` 参数(与 Canvas 的 nodeRadius=24 对齐),算出 `minGap = nodeRadius*2 + 8`(56px),`requiredWidth = totalWidth * minGap`,实际可用宽度取 `max(usableWidth, requiredWidth)`——节点永远不重叠,树若过宽则撑出画布(用户用 d3 zoom 平移查看,优于重叠)。数据流:buildVisualTree(tree, width, height, nodeRadius) → 节点坐标保证不重叠 → Canvas 渲染 + algorithmSteps 标注坐标同比例放大。GitHub Action 已配置 `push: branches:[main]`,单主分支下任何 push 即触发,无需改 workflow,只需 push 本地 7 个 commit。
9+
10+
**Tech Stack:** TypeScript 5.9, D3.js 7.9, React 19, Vite 7, GitHub Actions (actions/deploy-pages@v4)
11+
12+
**Risks:**
13+
- Task 1 改 buildVisualTree 签名增加 nodeRadius 参数,Canvas.tsx:61 和 algorithmSteps.ts:12 两处调用需同步传参 → 缓解:nodeRadius 设默认值 24,两处显式传 24 保持一致
14+
- 撑宽后树可能超出画布右侧不可见 → 缓解:d3 zoom 已支持平移,Canvas 有重置按钮;这是"可平移查看"明确优于"重叠遮挡"的取舍,符合用户"把叶子节点考虑进去"的意图
15+
- Task 2 push 是对外发布 → 缓解:用户已要求单主分支直接推送,且要求"任何变更都触发部署",授权明确
16+
17+
---
18+
19+
### Task 1: 修复 buildVisualTree 纳入节点直径约束消除叶子重叠
20+
21+
**Depends on:** None
22+
**Files:**
23+
- Modify: `src/utils/treeUtils.ts:56-128`(替换整个 buildVisualTree 函数)
24+
- Modify: `src/components/Canvas/Canvas.tsx:61`(调用处传 nodeRadius)
25+
- Modify: `src/utils/algorithmSteps.ts:12`(调用处传 nodeRadius)
26+
27+
- [ ] **Step 1: 重写 buildVisualTree 增加节点直径约束 — 保证相邻节点不重叠,树过宽则撑宽画布**
28+
29+
文件: `src/utils/treeUtils.ts:56-128`(替换整个 buildVisualTree 函数)
30+
31+
新逻辑:增加 `nodeRadius` 参数(默认 24),算最小间距 `minGap = nodeRadius*2 + 8``requiredWidth = totalWidth * minGap`,实际可用宽度 `effectiveWidth = max(usableWidth, requiredWidth)`。这样节点间距永远 ≥ minGap,不重叠;树过宽时坐标超出画布但可通过 d3 平移查看。
32+
33+
```typescript
34+
// 构建可视化树(包含位置信息)
35+
// 坐标算法:叶子按序铺开,内部节点取子树中点;y 按深度均匀分布
36+
// nodeRadius 纳入约束:相邻节点最小间距 = nodeRadius*2 + 8,保证不重叠
37+
// 树过宽时按 requiredWidth 撑宽画布,超出部分用 d3 zoom 平移查看(优于重叠遮挡)
38+
export const buildVisualTree = (
39+
root: TreeNode | null,
40+
width: number,
41+
height: number,
42+
showNullNodes: boolean = true,
43+
nodeRadius: number = 24
44+
): VisualNode | null => {
45+
if (!root) return null;
46+
47+
const padding = 40;
48+
const usableWidth = Math.max(width - padding * 2, 80);
49+
const treeHeight = getTreeHeight(root);
50+
// 每层高度,最深节点 y = treeHeight * levelHeight,不超过 height - padding
51+
const levelHeight = treeHeight > 0 ? Math.max((height - padding * 2) / treeHeight, 40) : 0;
52+
53+
// 相邻节点最小间距:直径 + 8px 间隙,确保叶子节点圆圈不重叠
54+
const minGap = nodeRadius * 2 + 8;
55+
56+
let nodeId = 0;
57+
58+
// 计算子树宽度(叶子=1,内部=左右子树宽度之和)
59+
const subtreeWidth = (node: TreeNode | null, depth: number): number => {
60+
if (!node) {
61+
// null 节点在显示时占 1 宽度,且不深入超过 treeHeight+1 层
62+
if (showNullNodes && depth <= treeHeight) return 1;
63+
return 0;
64+
}
65+
const lw = subtreeWidth(node.left, depth + 1);
66+
const rw = subtreeWidth(node.right, depth + 1);
67+
return lw + rw || 1; // 叶子节点(无子节点显示)宽度为 1
68+
};
69+
70+
const totalWidth = subtreeWidth(root, 0) || 1;
71+
// 实际可用宽度:取画布可用宽度与「不重叠所需宽度」的较大值
72+
const requiredWidth = totalWidth * minGap;
73+
const effectiveWidth = Math.max(usableWidth, requiredWidth);
74+
const xUnit = effectiveWidth / totalWidth;
75+
76+
const buildNode = (
77+
node: TreeNode | null,
78+
depth: number,
79+
leftX: number,
80+
parent: VisualNode | null,
81+
parentHasThisChild: boolean = true
82+
): VisualNode | null => {
83+
if (!node && !showNullNodes) return null;
84+
if (!node && !parentHasThisChild) return null;
85+
if (!node && depth > treeHeight + 1) return null;
86+
87+
const sw = subtreeWidth(node, depth) || 1;
88+
// 当前节点子树占据 [leftX, leftX + sw*xUnit] 区间,节点居中
89+
const x = leftX + (sw * xUnit) / 2 + padding;
90+
const y = depth * levelHeight + padding;
91+
92+
const visualNode: VisualNode = {
93+
id: `node-${nodeId++}`,
94+
val: node ? node.val : null,
95+
x,
96+
y,
97+
left: null,
98+
right: null,
99+
parent,
100+
isNull: !node,
101+
depth,
102+
};
103+
104+
if (node) {
105+
const lw = subtreeWidth(node.left, depth + 1);
106+
visualNode.left = buildNode(node.left, depth + 1, leftX, visualNode, true);
107+
visualNode.right = buildNode(node.right, depth + 1, leftX + lw * xUnit, visualNode, true);
108+
}
109+
110+
return visualNode;
111+
};
112+
113+
return buildNode(root, 0, 0, null, true);
114+
};
115+
```
116+
117+
- [ ] **Step 2: 修改 Canvas 调用处显式传 nodeRadius=24 — 与渲染节点半径对齐**
118+
119+
文件: `src/components/Canvas/Canvas.tsx:61`
120+
121+
```typescript
122+
// 构建可视化树(padding 已内置,坐标在 [padding, width-padding] 区间;nodeRadius 约束保证不重叠)
123+
const visualRoot = buildVisualTree(treeData, width, height, true, 24);
124+
```
125+
126+
- [ ] **Step 3: 修改 algorithmSteps 调用处显式传 nodeRadius=24 — 与 Canvas 坐标布局一致**
127+
128+
文件: `src/utils/algorithmSteps.ts:12`
129+
130+
```typescript
131+
// 显示 null 节点,与 Canvas.tsx 渲染时保持一致,避免坐标布局错位
132+
const visualRoot = buildVisualTree(root, canvasWidth, canvasHeight, true, 24);
133+
```
134+
135+
- [ ] **Step 4: 验证编译通过 — 确认签名变更无类型错误**
136+
137+
Run: `npx tsc -b --noEmit`
138+
Expected:
139+
- Exit code: 0
140+
- Output does NOT contain: "error TS"
141+
142+
- [ ] **Step 5: 实测验证叶子节点不重叠 — 确认完全二叉树 15 节点间距 ≥ 56px**
143+
144+
Run: `npx tsx -e "import {buildTreeFromArray,buildVisualTree,flattenVisualTree} from './src/utils/treeUtils'; const t=buildTreeFromArray([8,4,12,2,6,10,14,1,3,5,7,9,11,13,15]); const r=buildVisualTree(t,778,687,true,24); const ns=flattenVisualTree(r!); const byD=new Map<number,number[]>(); for(const n of ns){if(!byD.has(n.depth))byD.set(n.depth,[]);byD.get(n.depth)!.push(n.x);} const deepest=[...byD.entries()].sort((a,b)=>b[0]-a[0])[0]; const xs=deepest[1].sort((a,b)=>a-b); let g=Infinity;for(let i=1;i<xs.length;i++)g=Math.min(g,xs[i]-xs[i-1]); console.log('minGap='+g.toFixed(1)+'px (需>=56) '+(g>=56?'✓不重叠':'❌重叠'));" 2>&1 | grep -v "NODE_TLS\|trace-warnings"`
145+
Expected:
146+
- Exit code: 0
147+
- Output contains: "✓不重叠"
148+
- Output does NOT contain: "❌重叠"
149+
150+
- [ ] **Step 6: 提交**
151+
152+
Run: `git add src/utils/treeUtils.ts src/components/Canvas/Canvas.tsx src/utils/algorithmSteps.ts && git commit -m "fix(canvas): 坐标计算纳入节点直径约束,消除叶子节点重叠遮挡"`
153+
154+
---
155+
156+
### Task 2: push 到 main 触发部署并验证线上为最新代码
157+
158+
**Depends on:** Task 1
159+
**Files:**
160+
- 无文件修改,纯部署验证任务
161+
162+
- [ ] **Step 1: 确认 GitHub Action 触发条件已满足 — 验证 workflow 在单主分支下覆盖任何变更**
163+
164+
Run: `cat .github/workflows/deploy.yml | grep -A3 "^on:"`
165+
Expected:
166+
- Exit code: 0
167+
- Output contains: "push:" and "branches: [ main ]"
168+
- 说明:单主分支工作流下,main 的任何 push 即触发,无需 paths 过滤
169+
170+
- [ ] **Step 2: 本地构建验证 — 确保 push 前 CI 不会失败**
171+
172+
Run: `npm run build`
173+
Expected:
174+
- Exit code: 0
175+
- Output contains: "✓ built in"
176+
- Output does NOT contain: "error"
177+
178+
- [ ] **Step 3: push 到 origin/main — 触发 GitHub Actions 自动部署**
179+
180+
Run: `git push origin main`
181+
Expected:
182+
- Exit code: 0
183+
- Output contains: "main -> main"
184+
185+
- [ ] **Step 4: 监听 Actions 运行直到成功 — 确认 build + deploy 通过**
186+
187+
Run: `sleep 5 && gh run watch $(gh run list --limit 1 --json databaseId --jq '.[0].databaseId') --exit-status`
188+
Expected:
189+
- Exit code: 0
190+
- Output contains: "✓ build" and "✓ deploy"
191+
192+
- [ ] **Step 5: 验证线上部署的是最新 commit — 比对远程与本地 HEAD**
193+
194+
Run: `git rev-list --count origin/main..HEAD && git rev-list --count HEAD..origin/main`
195+
Expected:
196+
- 第一个输出: 0(本地不再领先远程)
197+
- 第二个输出: 0(远程不再领先本地)
198+
199+
- [ ] **Step 6: 提交**
200+
201+
Run: 无需提交(Task 1 已提交代码,Task 2 是 push 部署验证,无代码变更)

src/components/Canvas/Canvas.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,8 +57,8 @@ export const Canvas: React.FC<CanvasProps> = ({ treeData, currentStep, onDimensi
5757
// margin 仅用于 g 容器整体偏移,padding 已在 buildVisualTree 内部处理
5858
const margin = { top: 20, right: 20, bottom: 40, left: 20 };
5959

60-
// 构建可视化树(padding 已内置,坐标在 [padding, width-padding] 区间)
61-
const visualRoot = buildVisualTree(treeData, width, height, true);
60+
// 构建可视化树(padding 已内置,坐标在 [padding, width-padding] 区间;nodeRadius 约束保证不重叠
61+
const visualRoot = buildVisualTree(treeData, width, height, true, 24);
6262
if (!visualRoot) return;
6363

6464
const nodes = flattenVisualTree(visualRoot);

src/utils/algorithmSteps.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ export const generateAlgorithmSteps = (
99
): AlgorithmStep[] => {
1010
const steps: AlgorithmStep[] = [];
1111
// 显示 null 节点,与 Canvas.tsx 渲染时保持一致,避免坐标布局错位
12-
const visualRoot = buildVisualTree(root, canvasWidth, canvasHeight, true);
12+
const visualRoot = buildVisualTree(root, canvasWidth, canvasHeight, true, 24);
1313
const nodeMap = new Map<number, VisualNode>();
1414

1515
// 建立值到节点的映射

src/utils/treeUtils.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -55,12 +55,14 @@ export const getNodeCount = (node: TreeNode | null): number => {
5555

5656
// 构建可视化树(包含位置信息)
5757
// 坐标算法:叶子按序铺开,内部节点取子树中点;y 按深度均匀分布
58-
// padding 留出节点半径(24)+标注空间,确保不溢出画布
58+
// nodeRadius 纳入约束:相邻节点最小间距 = nodeRadius*2 + 8,保证不重叠
59+
// 树过宽时按 requiredWidth 撑宽画布,超出部分用 d3 zoom 平移查看(优于重叠遮挡)
5960
export const buildVisualTree = (
6061
root: TreeNode | null,
6162
width: number,
6263
height: number,
63-
showNullNodes: boolean = true
64+
showNullNodes: boolean = true,
65+
nodeRadius: number = 24
6466
): VisualNode | null => {
6567
if (!root) return null;
6668

@@ -70,6 +72,9 @@ export const buildVisualTree = (
7072
// 每层高度,最深节点 y = treeHeight * levelHeight,不超过 height - padding
7173
const levelHeight = treeHeight > 0 ? Math.max((height - padding * 2) / treeHeight, 40) : 0;
7274

75+
// 相邻节点最小间距:直径 + 8px 间隙,确保叶子节点圆圈不重叠
76+
const minGap = nodeRadius * 2 + 8;
77+
7378
let nodeId = 0;
7479

7580
// 计算子树宽度(叶子=1,内部=左右子树宽度之和)
@@ -85,7 +90,10 @@ export const buildVisualTree = (
8590
};
8691

8792
const totalWidth = subtreeWidth(root, 0) || 1;
88-
const xUnit = usableWidth / totalWidth;
93+
// 实际可用宽度:取画布可用宽度与「不重叠所需宽度」的较大值
94+
const requiredWidth = totalWidth * minGap;
95+
const effectiveWidth = Math.max(usableWidth, requiredWidth);
96+
const xUnit = effectiveWidth / totalWidth;
8997

9098
const buildNode = (
9199
node: TreeNode | null,

0 commit comments

Comments
 (0)