做晶圆测试软件这块时间长了,你会发现一个特别有意思的现象:Map图工具本身看起来并不复杂,就是一堆彩色的格子,但用户真正用起来之后,对交互的要求却非常苛刻。最近我们团队就接到一个需求,要在晶圆Map图里实现芯片的Ctrl多选功能,说白了就是让用户按住Ctrl键,用鼠标点选或者框选多颗Die,方便后续批量复判、失效分析和良率统计。功能听起来很小,真正动手做才发现里面全是细节:坐标变换、命中检测、大规模渲染、浏览器事件干扰,每一项都能让你踩到坑。
这篇文章我会把整个开发过程完整拆开,从需求分析、方案选型、核心代码实现到上线前的问题排查,都按我实际操作的顺序写出来。如果你也在做晶圆Map图相关的数据分析工具,或者需要处理几万级Canvas节点交互,这篇内容应该能帮你少走不少弯路。
1. 需求其实不简单:从“点选芯片”到“批量操作”
1.1 晶圆Map图的基本认知
晶圆Map图是晶圆测试阶段最核心的产出物之一。一颗晶圆上排列着成千上万颗Die,测试机台依次接触每颗Die,测完以后把结果记录成带有坐标的bin值,再经过数据整理画成Map图。工程师通过Map图快速判断整片晶圆的良率分布、失效聚集区域、边缘损伤情况,甚至能看出某些工艺批次的典型问题。
我手头接触过的项目,测过GD32F427、TMS320C6748、ESP32这类不同定位的芯片。小尺寸Die的一片晶圆能有五六万颗,大Die也有几千颗。数据量大不大,直接影响交互方案选型。Map图上每颗Die都用不同的颜色表示bin分类,pass是一类颜色,各种fail bin又是其他颜色。这张图既是工程分析入口,也是产品质量追溯的重要依据。
1.2 为什么需要Ctrl多选
传统Map图工具往往只提供单击查看Die信息、矩形框选放大这类基础交互。真正做失效分析的时候,工程师经常需要从Map图中挑出分布在多个区域的同一类异常Die,去做后续的物理失效分析或电镜观察。比如有一次客户在GD32F427的批次里发现某几颗Die出现相同特征的漏电,他们需要把这些Die全部标记出来导出一个清单,但它们在Map图上并不在同一个连续区域。
没有多选功能时,只能把坐标一个个抄下来,或者反复框选再合并结果,极其痛苦。所以当业务方提出“能不能像Windows文件管理器一样,按住Ctrl点选、框选多个芯片”时,我第一反应是这需求太合理了。用户的心智模型已经很成熟,我们要做的就是尽量贴近系统级交互习惯。
1.3 需求边界与交互约定
需求听完以后,不能马上动手写代码,先要定义清楚交互边界。我们和测试工程师、产品经理拉了一个短会,最终确定了以下行为约定:
- 鼠标单击一颗Die:先清空已有选择,只选中当前这颗。
- Ctrl+单击一颗Die:切换该Die的选中状态,原先选中的其他Die不受影响。
- Shift+单击两颗Die:自动选中以此两点为对角顶点的矩形区域内的所有Die(沿用文件管理器的连选习惯)。
- 空白区域拖拽:拉出一个矩形选区,松开后选中矩形内所有Die,并清空之前选择。
- Ctrl+空白区域拖拽:矩形选区中的Die追加到当前选择集,不清空之前选择。
- 点击空白区域且无拖拽:清空所有选择。
- 暂时不支持Ctrl+A全选。
这些规则列出来以后,我发现后面所有开发工作都变得清晰了。真正让我意外的是,有些用户还希望Ctrl+单击是“追加”而不是“切换”,我们最终通过配置项支持了两种模式,但默认保持系统文件管理器的切换逻辑。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:为什么我选了Canvas而不是DOM/SVG
2.1 三种渲染方案对比
Map图本质上是大量离散小矩形,每个Die的颜色可能不同。我第一直觉是直接生成DOM节点,每个节点就是一个Die,用CSS控制颜色和边框。小规模测试没问题,真放到五万颗Die的Map上,页面直接卡成PPT。SVG也类似,节点数量一大,事件绑定和重绘都是灾难。
Canvas这时就体现出明显优势:它是一个像素画布,所有东西最终绘制成位图,渲染压力只跟绘制内容复杂度有关。我整理了一个对比表,方便后续同事参考:
| 方案 | 实现难度 | 5万节点性能 | 交互命中检测 | 适用场景 |
|---|---|---|---|---|
| DOM | 低 | 非常差 | 浏览器自带 | 数据量少于1000颗 |
| SVG | 中 | 较差 | 通过事件绑定 | 数据量少于5000颗 |
| Canvas | 高 | 良好 | 需要自己计算 | 数据量大于1万颗 |
我在本地用三万个Die做了基准测试,DOM方案滚动和悬停都会掉到个位数帧率,Canvas方案在普通办公本上也能稳定60帧。考虑到Map图数据量会随着芯片尺寸缩小继续增加,我直接锁定了Canvas。
2.2 分层架构设计
很多人用Canvas做Map图,会陷入一个误区:把所有绘制逻辑堆在一个方法里,每次交互都重新画一遍全部内容。这样在数据量少的时候没问题,数据量上来以后就会卡顿。
我更习惯把整个视图拆成两层,物理层用两个Canvas堆叠实现。底层是静态Map层,只负责把Die颜色、背景、bin图例绘制到离屏Canvas上,作为缓存;上层是交互层,只负责绘制当前选中高亮、拖拽选区、悬停效果。这样每次鼠标移动或点选时,底层位图不需要重新生成,交互层只需要清空后重新绘制当前选中的少量元素,效率非常可观。
2.3 技术栈与代码组织
这个功能我用了TypeScript开发,核心逻辑全部封装成框架无关的纯TS模块,上层接入的是React项目,但换到Vue甚至原生JS也一样能用。目录结构大致是这样:
coordinate.ts:世界坐标、屏幕坐标、Die坐标之间的转换。map-data.ts:Map原始数据解析和归一化。renderer.ts:Canvas绘制,包括静态层和交互层。selection-manager.ts:选择集状态管理和命中检测算法。map-view.ts:对外暴露的组件入口,处理鼠标键盘事件。
很多团队一上来就在组件里写一堆事件回调,看着方便,后面维护成本极高。我把状态管理单独拎出来,选择逻辑不依赖UI框架,后续如果需要复用或测试就很轻松。
3. 核心实现:坐标变换、命中检测与多选算法
3.1 坐标系统:从世界坐标到屏幕坐标
Map数据里的Die坐标一般是行列索引,例如{row: 120, col: 30}。为了转成屏幕位置,需要知道Map的原点坐标、Die的横向间距和纵向间距,通常这些参数在测试数据文件里会有。整个转换公式其实很直观:
typescript复制interface Point { x: number; y: number; }
interface DieCoordinate { row: number; col: number; }
function dieToWorld(die: DieCoordinate, origin: Point, pitchX: number, pitchY: number): Point {
return {
x: origin.x + die.col * pitchX,
y: origin.y + die.row * pitchY,
};
}
function worldToDie(point: Point, origin: Point, pitchX: number, pitchY: number): DieCoordinate {
const col = Math.floor((point.x - origin.x) / pitchX);
const row = Math.floor((point.y - origin.y) / pitchY);
return { row, col };
}
实际工程中还要考虑视图平移和缩放。用户拖动Map图后,同一颗Die的屏幕位置会变化,所以我额外维护了一个Viewport对象,包含panX、panY、scale,所有鼠标坐标先经过viewport反变换,转成世界坐标后再去查Die:
typescript复制interface Viewport {
panX: number;
panY: number;
scale: number;
}
function screenToWorld(sx: number, sy: number, vp: Viewport): Point {
return {
x: (sx - vp.panX) / vp.scale,
y: (sy - vp.panY) / vp.scale,
};
}
这段代码是后面所有交互的基础,一定要保证准确。我建议在实现初期就写好单测,因为后面所有点选、框选都建立在这套转换上,一旦转换错了,整个多选功能就会偏移。
3.2 命中检测:别用遍历,用哈希索引
点选Die最简单粗暴的方式是遍历所有Die,计算当前鼠标世界坐标有没有落在该Die的矩形范围内。五万颗Die遍历一次并不算太慢,但框选、hover、高亮这些操作一多,整体体验就会下降。
我用的方案是构建一个哈希索引,以row_col作为键,Die信息作为值。这样点选的时候,只需要把鼠标世界坐标反算成row和col,再查表即可,时间复杂度是O(1):
typescript复制class DieIndex {
private map = new Map<string, DieInfo>();
constructor(dies: DieInfo[]) {
for (const die of dies) {
this.map.set(`${die.row}_${die.col}`, die);
}
}
get(row: number, col: number): DieInfo | undefined {
return this.map.get(`${row}_${col}`);
}
}
框选时,我们获取拖拽起点的世界坐标和终点的世界坐标,分别转成两个Die坐标,然后取行列的min/max范围,直接在这个范围内通过哈希索引遍历矩形中的每颗Die。对于Map图这种规则排列的矩形网格,这种方案比几何相交判断简单得多,实际表现也很快。
3.3 Ctrl多选状态管理
选择集管理使用Set<number>存储,编号用row * maxCol + col生成,存整数而不是存对象,这样比较和序列化都方便。通过之前定义的交互约定,我设计了完整的点击与框选处理流程。
核心逻辑大致如下:
typescript复制type SelectionMode = 'single' | 'toggle';
class SelectionManager {
private selected = new Set<number>();
private maxCol: number;
constructor(maxCol: number) {
this.maxCol = maxCol;
}
private key(row: number, col: number): number {
return row * this.maxCol + col;
}
applyPointSelection(row: number, col: number, modifier: boolean, mode: SelectionMode) {
const k = this.key(row, col);
if (modifier) {
if (mode === 'toggle') {
this.selected.has(k) ? this.selected.delete(k) : this.selected.add(k);
} else {
// 追加模式
this.selected.add(k);
}
} else {
this.selected.clear();
this.selected.add(k);
}
}
applyRectSelection(rowMin: number, colMin: number, rowMax: number, colMax: number, modifier: boolean) {
if (!modifier) {
this.selected.clear();
}
for (let r = rowMin; r <= rowMax; r++) {
for (let c = colMin; c <= colMax; c++) {
this.selected.add(this.key(r, c));
}
}
}
}
这里有一个关键点:mousedown的时候不要立刻执行选择操作,因为用户可能是要拖拽,也可能只是点一下。我给鼠标移动设置了5像素的阈值,只有移动距离超过5像素才进入拖拽模式,否则在mouseup时按单击处理。这样能避免“按住Ctrl准备拖拽,结果手一抖就选了一颗Die”的误操作。
3.4 绘制高亮与双缓冲
选中状态在Map图上要有明显的视觉反馈。我给每颗选中的Die画一个亮色边框,内部叠加一层半透明颜色。为了让边框在不同缩放级别下看起来粗细一致,线宽需要除以当前的scale:
typescript复制function drawSelection(ctx: CanvasRenderingContext2D, die: DieInfo, scale: number) {
ctx.save();
ctx.strokeStyle = '#00ff88';
ctx.lineWidth = 1.5 / scale;
ctx.fillStyle = 'rgba(0, 255, 136, 0.3)';
ctx.fillRect(die.x, die.y, die.width, die.height);
ctx.strokeRect(die.x, die.y, die.width, die.height);
ctx.restore();
}
Canvas自带像素缓冲,本身不像传统UI那样容易闪屏,但为了提高性能,我在静态层使用了离屏Canvas缓存。初始化时把整个Map绘制到离屏画布上,交互层每次刷新只需要drawImage把静态层贴过来,再绘制当前选中的单独元素,整个过程非常流畅。
为了进一步压缩绘制成本,我在绘制交互层时只遍历当前viewport可视范围内的Die,不可见的Die直接跳过。这个场景用四叉树或网格索引更精细,但Map图Die的坐标是规则的,遍历可视范围内Die的开销已经完全可以接受。
3.5 业务联动:选中后的统计与操作
多选本身只是起点,用户选中一堆芯片后,大概率要做以下几件事:看选中的芯片bin分布、标记为复判、导出坐标清单、生成分析报告。我在选择集变化后抛出了一个selectionChange事件,事件载荷包含选中Die数组和当前选中数量。
这样上层表格、详情面板、统计栏都可以监听这个事件自动更新。我们产品里最受欢迎的一个功能是状态栏实时显示“已选中 128 颗Die,其中Pass 120颗,Fail 8颗,Fail占比6.25%”。工程师在做批量复判时,眼睛不用离开地图就能确认选对了没有。
4. 联调、测试与问题排查
4.1 Canvas坐标偏移问题
第一个版本提交给测试同事后,反馈是点击位置和实际选中的Die总是错位一格,尤其在高清屏上特别明显。我查了半天,问题出在Canvas的CSS尺寸和绘图缓冲尺寸不一致。
现代浏览器为了适配高分屏,canvas.width和canvas.height需要设置成CSS宽高乘以devicePixelRatio,否则绘制出来的内容会模糊。但如果只设置了尺寸,没有同步设置ctx.setTransform,鼠标坐标直接用clientX - rect.left就会和绘图坐标差一个比例。正确的做法是:
typescript复制function resizeCanvas(canvas: HTMLCanvasElement) {
const dpr = window.devicePixelRatio || 1;
const rect = canvas.getBoundingClientRect();
canvas.width = rect.width * dpr;
canvas.height = rect.height * dpr;
const ctx = canvas.getContext('2d')!;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
处理完以后,鼠标坐标转换成世界坐标时,直接用event.clientX - rect.left即可,因为ctx已经把高DPI的缩放处理好了。这个坑几乎每个Canvas项目都会遇到,我建议把坐标转换统一封装在coordinate.ts里,一旦出错只改一个地方。
4.2 Ctrl键“卡住”问题
插线板一样的老问题:用户按住Ctrl键选中了几颗Die,然后切到另一个窗口处理邮件,回到系统时发现鼠标悬停样式变成了光标移动,或者所有点击都变成了追加选择。这类问题本质是Ctrl键的keyup事件在窗口失去焦点时丢失了,前端状态里ctrlKey一直停留在true。
解决方法很简单,在window上监听blur事件,强制重置Ctrl状态。另外在macOS上,用户的“Ctrl”多选习惯更多会按Command键,所以我在判断修饰键时用的是event.ctrlKey || event.metaKey,避免Mac用户按Cmd没反应的尴尬。
还有一个容易忽略的点:Ctrl+A、Ctrl+滚轮这类浏览器默认行为会干扰Map图交互。我在Canvas容器上加了键盘监听,拦截常见的Ctrl组合键并阻止默认行为,Map图内部的Ctrl+滚轮则定义为缩放操作。
4.3 大量Die时的性能问题
性能优化是最能跑出成就感的部分。最开始我把所有Die都画在主Canvas上,点击一颗Die后触发全量重绘,测试数据量六万颗Die,一次重绘要120ms,明显感觉到卡顿。
优化思路分三步:第一步,静态Map层离屏化,Map背景和所有Die的基础颜色只画一次;第二步,交互层只绘制选中和hover状态的Die,视觉上几乎不需要重绘;第三步,绘制静态层时也只绘制当前可见范围,缩放和拖动时按需绘制。
优化后点选一次的耗时稳定在16ms以内,跟随手操作几乎没有区别。如果你处理的Die数量能到二十万甚至更多,建议再配合空间网格索引做框选遍历优化,我现在的五万规模用哈希索引已经足够。
4.4 误选与边界情况
框选时边缘Die是否选中,这个需要和业务方确认。我最初采用“矩形与Die矩形有交集就选中”,结果用户一框选,边缘半颗Die也被选中,看着很不自然。后来改成“Die的中心点落在矩形内才算选中”,用户反馈视觉和逻辑都合理。建议把这个规则做成可配置选项,毕竟不同工厂习惯不一致。
缩放比例很小时,多颗Die会重叠在十几个像素内,框选容易全选。针对这种情况,我们在低缩放级别(比如单颗Die的像素宽度小于3px)时做了降级处理:拖拽框选不再框选多个Die,而是直接选择距离矩形中心最近的一颗Die,避免误操作。
Map图不是永远都是正方向的。有些晶圆数据带有notch角度旋转,Die坐标本身是旋转后的物理坐标,画到屏幕前需要先做旋转变换。反算屏幕点到Die坐标时,也要先把屏幕世界坐标旋转回去。这个旋转参数不处理好,多选功能在旋转Map上会全部偏掉。
4.5 与后端保存/回读
多选结果如果要保存到服务端,不要直接把整个选中Die数组全量提交。五万颗Die里选了两万颗,JSON体积非常大。更合理的做法是把选中的行列区间压缩成矩形列表,或者使用游程编码(RLE)按行记录连续选区。我们最终按照“矩形列表”方式存储,每个矩形记录{rowMin, colMin, rowMax, colMax},后端再合并相邻矩形,前端加载时重新展开成Set。
另外我给选择功能加了一个快照机制,用户能通过快捷键Ctrl+Shift+S保存当前选择集,之后可以随时恢复继续分析。这个功能不在最初需求里,但做完以后测试工程师直呼好用,建议你也给选择管理器预留一个saveSnapshot()接口。
5. 我踩过的几个坑和最终建议
回过头看这次Ctrl多选功能开发,最大的感受是“多选功能”四个字背后的复杂程度远超预期。如果一开始就把交互规则、坐标系、渲染分层想清楚,后面的实现会顺利很多。
第一,不要过度设计。最初我试图用纯DOM实现选中层,觉得这样好调试,后来发现三万颗Die直接卡死,果断切换成Canvas。技术选型还是要看数据规模,不要为了快速上手牺牲性能。
第二,交互行为一定要先跟用户对齐。我见过有的工程师希望Ctrl+单击是“追加选择”而不是“切换”,也有人觉得Shift连选应该改成Shift+点击而不是矩形连选。我们最后把几个关键交互做成了配置项,默认对齐操作系统文件管理器习惯。
第三,一定要有实时反馈。选中数量、当前选中的bin分布这些信息如果不在状态栏显示,用户多选时很容易怀疑自己操作没生效。我们加上统计栏之后,相关工单量明显下降。
第四,选择管理器尽量独立成模块。这次实现的SelectionManager只依赖Die的坐标数据,不依赖任何UI框架。后来公司另一个项目要做相似功能,我直接把模块迁移过去,只改了几行配置就复用了。
最后再分享一个小技巧。如果你后续要做更复杂的选择方式,比如Lasso套索选择、按Bin号批量选择、按良率阈值范围选择,尽量把当前这套基于Set的选择模型继续沿用,因为各种选择操作最终都在往同一个集合里加元素。多选只是基础能力,一个稳定、可扩展的选择模型才是真正值得沉淀的资产。
