上一篇我们讲解了实现无限画布、标尺、网格绘制,本篇我们讲解下类figma的选择工具的碰撞检测实现,但这是我基于以往经验的一种猜测,figma的选中工具更多的其实是实现一种优化用户体验的方式策略,并非某种固定算法
实现效果

🌟 主要设计
- 🎯 智能化:基于用户意图的智能选择,而非简单的 Z-index 排序
- ⚡ 高性能:视口感知空间分区,支持大量节点的高效选择
- 🎨 专业级:达到 Figma 级别的用户体验标准
- 🔧 可扩展:模块化设计,易于维护和扩展
✨ 核心特性详解
1. 智能选择优先级
文本节点优先策略
typescript
// 用户点击文本时的心理期望分析
用户看到:"登录按钮"
用户意图:选中文字进行编辑 ✅
用户不想要:选中整个按钮容器 ❌
// 实现:文本节点获得最高优先级分数
priority += node.type === 'text' ? 100 : 50;
小节点优先策略
typescript
// 用户精确点击小元素的期望
用户行为:精确点击小图标
用户意图:选中这个特定的小元素 ✅
用户不想要:误选背景大容器 ❌
// 实现:面积越小,优先级越高
const areaScore = Math.max(0, 50 - Math.log10(area + 1) * 10);
边缘检测优化
当用户精确点击节点边缘时,系统会给予额外的优先级加分,确保选中用户真正想要的元素。
距离权衡算法
点击位置越靠近节点中心,选择概率越高,体现用户的精确意图。
2. 视口感知性能优化
空间分区
typescript
// 传统方式:固定全局网格
网格范围: 10000 x 10000 px
网格数量: (10000/200) × (10000/200) = 2500 个格子
内存占用: 2500 个格子 × 平均节点数 = 大量内存浪费
维护开销: 无论用户看哪里都要维护全部格子
js
画布坐标系统 (cellSize = 200):
0 200 400 600 800 (x轴)
0 ┌─────┬─────┬─────┬─────┐
│ 0,0 │ 1,0 │ 2,0 │ 3,0 │
200 ├─────┼─────┼─────┼─────┤
│ 0,1 │ 1,1 │ 2,1 │ 3,1 │
400 ├─────┼─────┼─────┼─────┤
│ 0,2 │ 1,2 │ 2,2 │ 3,2 │
600 ├─────┼─────┼─────┼─────┤
│ 0,3 │ 1,3 │ 2,3 │ 3,3 │
800 └─────┴─────┴─────┴─────┘
(y轴)
例子:
- 点 (150, 350) → 格子 "0,1"
- 点 (450, 100) → 格子 "2,0"
- 点 (320, 520) → 格子 "1,2"
视口感知,根据缩放、移动,重绘网格
js
场景1:用户缩放到10%查看全局
┌────────────────────────────────────┐
│ 😵 仍使用200px网格,太密集了 │
│ 网格过多,内存和计算开销大 │
└────────────────────────────────────┘
场景2:用户缩放到500%查看细节
┌──────────┐
│ 😵 仍使用 │
│ 200px网格 │
│ 太稀疏了 │
└──────────┘
typescript
// 视口感知:动态网格
用户实际可见: 1920 x 1080 px
视口网格数量: (1920/200) × (1080/200) ≈ 54 个格子
内存节省
动态调整: 用户缩放/平移时重建
3. 多种框选模式
模式对比表
模式 | 描述 | 适用场景 | 行为 |
---|---|---|---|
INTERSECTS | 相交即选中 | 快速多选 | 选择框碰到就选中 |
CONTAINS | 完全包含 | 精确控制 | 节点完全在选择框内 |
CENTER | 中心点选择 | 复杂嵌套 | 中心点在选择框内 |
算法实现
ts
// INTERSECTS 模式 - 矩形相交算法
isSelected = !(
nodeRight < selectionLeft || // 节点在选择框左侧
nodeLeft > selectionRight || // 节点在选择框右侧
nodeBottom < selectionTop || // 节点在选择框上方
nodeTop > selectionBottom // 节点在选择框下方
);
// CONTAINS 模式 - 完全包含算法
isSelected = (
nodeLeft >= selectionLeft &&
nodeRight <= selectionRight &&
nodeTop >= selectionTop &&
nodeBottom <= selectionBottom
);
// CENTER 模式 - 中心点算法
const centerX = node.x + node.w / 2;
const centerY = node.y + node.h / 2;
isSelected = (
centerX >= selectionLeft && centerX <= selectionRight &&
centerY >= selectionTop && centerY <= selectionBottom
);
4. 智能交互特性
- Tab 遍历: Tab 键正向遍历节点,Shift+Tab 反向遍历
- 全选优化: Ctrl/Cmd+A 智能全选当前页面所有节点
- ESC 清除: ESC 键快速清除所有选择
- 拖拽阈值: 3 像素智能拖拽检测,避免误触
🏗️ 技术架构与实现原理
1. 三层架构设计
scss
┌─────────────────────────────────────────────────────────────────┐
│ SmartSelectionHandler │
│ (交互逻辑层 - 用户输入处理) │
│ • 鼠标事件处理 • 键盘快捷键 • 选择状态管理 │
└─────────────────────┬───────────────────────────────────────────┘
│
┌─────────────────────▼───────────────────────────────────────────┐
│ SmartHitTest │
│ (算法引擎层 - 智能碰撞检测与优先级计算) │
│ • 优先级算法 • 碰撞检测 • 性能优化 │
└─────────────────────┬───────────────────────────────────────────┘
│
┌─────────────────────▼───────────────────────────────────────────┐
│ ViewportAwareSpatialGrid │
│ (数据结构层 - 视口感知空间分区) │
│ • 动态网格管理 • 视口计算 • 空间索引 │
└─────────────────────────────────────────────────────────────────┘
2. 视口感知空间网格系统
核心理念
只为用户当前可见的视口区域建立空间索引,而不是整个画布,这是现代设计工具的核心优化技术。
动态视口计算
typescript
calculateCurrentViewport(canvas: HTMLCanvasElement): ViewportInfo {
// 屏幕坐标 → 世界坐标转换
const topLeft = coordinateSystemManager.screenToWorld(0, 0);
const bottomRight = coordinateSystemManager.screenToWorld(width, height);
return {
visibleBounds: { left, top, right, bottom }, // 用户看到的世界坐标范围
zoomLevel: getCurrentZoomLevel(), // 当前缩放级别
canvasSize: { width, height } // 画布尺寸
};
}
重建算法
typescript
shouldRebuildGrid(newViewport: ViewportInfo): boolean {
// 条件1: 缩放变化超过30% → 网格密度要调整
if (zoomChange / currentZoom > 0.3) return true;
// 条件2: 视口移出网格边界 → 需要新的覆盖区域
if (viewportOutOfBounds()) return true;
// 条件3: 网格利用率<20% → 太多空格子浪费内存
if (utilization < 0.2) return true;
// 条件4: 1秒内防重建 → 避免频繁操作
if (timeSinceLastRebuild < 1000) return false;
}
自适应网格算法
根据缩放效果匹配不同的网格大小
typescript
getAdaptiveCellSize(zoomLevel: number): number {
const baseCellSize = 200;
// 核心公式:cellSize = baseSize / √zoomLevel
const adaptedSize = baseCellSize / Math.sqrt(Math.max(zoomLevel, 0.1));
// 限制在合理范围内:50px - 500px
return Math.max(50, Math.min(500, adaptedSize));
}
缩放适配效果:
ini
用户缩放到10% → cellSize = 200/√0.1 ≈ 632px (大格子,少内存)
用户缩放到100% → cellSize = 200/√1.0 = 200px (标准格子)
用户缩放到400% → cellSize = 200/√4.0 = 100px (小格子,高精度)
缓冲区策略
typescript
rebuildForViewport(viewport: ViewportInfo): void {
// 缓冲区 = 3个格子的距离
const buffer = this.adaptiveCellSize * 3;
this.gridBounds = {
left: viewport.visibleBounds.left - buffer, // 向左扩展
top: viewport.visibleBounds.top - buffer, // 向上扩展
right: viewport.visibleBounds.right + buffer, // 向右扩展
bottom: viewport.visibleBounds.bottom + buffer // 向下扩展
};
}
缓冲区作用:
- 🏃♂️ 提前准备:用户小幅度移动时不需要重建
- 🎯 减少重建频率:避免频繁的网格重建
- ⚡ 提升响应性:移动到边缘区域时已有数据准备好
3. 优先级算法详解
多因子评分系统
typescript
function calculatePriority(point: Point, node: BaseNode): number {
let priority = 0;
// 因子1: 节点类型权重 (0-100分)
priority += getTypeWeight(node.type);
// 因子2: 面积反比权重 (0-50分) - 小节点优先
priority += Math.max(0, 50 - Math.log10(node.area + 1) * 10);
// 因子3: 距离中心权重 (0-20分) - 中心点击优先
priority += getDistanceWeight(point, node.center);
// 因子4: 边缘检测权重 (0-15分) - 边缘点击奖励
priority += getEdgeWeight(point, node.bounds);
return priority;
}
权重分配策略
因子 | 权重范围 | 设计理念 | 实际效果 |
---|---|---|---|
节点类型 | 0-100分 | 文本>图形>容器 | 点击文本优先选中文本 |
节点大小 | 0-50分 | 越小越容易选中 | 小图标不被大背景遮盖 |
点击距离 | 0-20分 | 越近中心越优先 | 精确点击获得奖励 |
边缘检测 | 0-15分 | 边缘点击更精确 | 边界操作更准确 |
4. 碰撞检测算法详解
三级检测流水线
typescript
// 第一级: 空间分区预筛选 (性能优化)
const candidates = spatialGrid.getCandidateNodes(point);
// 第二级: AABB包围盒快速检测 (粗筛选)
const aabbCandidates = candidates.filter(node =>
quickAABBTest(point, node)
);
// 第三级: 精确几何检测 (精确结果)
const validNodes = aabbCandidates.filter(node =>
precisionHitTest(point, node)
);
旋转节点检测原理
无旋转节点 - 简单AABB检测:
typescript
return point.x >= node.x && point.x <= node.x + node.w &&
point.y >= node.y && point.y <= node.y + node.h;
旋转节点 - OBB(有向包围盒)检测:,对于特殊的path元素,还需要检测包围的path
typescript
// 1. 将点转换到节点本地坐标系
const relativeX = point.x - node.centerX;
const relativeY = point.y - node.centerY;
// 2. 应用反向旋转矩阵
const cos = Math.cos(-node.rotation);
const sin = Math.sin(-node.rotation);
const rotatedX = relativeX * cos - relativeY * sin;
const rotatedY = relativeX * sin + relativeY * cos;
// 3. 在本地坐标系中进行AABB检测
return rotatedX >= -node.w/2 && rotatedX <= node.w/2 &&
rotatedY >= -node.h/2 && rotatedY <= node.h/2;
5. 数据流与执行流程
点选操作流程
- 用户点击 → 获取鼠标坐标
- 坐标转换 → 屏幕坐标转世界坐标
- 视口网格查找 → 获取候选节点列表
- AABB预检测 → 快速排除明显不符合的节点
- 精确碰撞检测 → 处理旋转、圆角等复杂形状
- 优先级计算 → 多因子评分排序
- 选择最佳节点 → 返回得分最高的节点
- 更新选择状态 → 更新UI状态
- 触发重新渲染 → 更新视觉反馈
框选操作流程
- 开始拖拽 → 记录起始点
- 拖拽阈值检测 → 避免误触发框选
- 创建选择框 → 显示选择框UI
- 实时更新选择框 → 跟随鼠标移动
- 视口网格查找 → 获取区域内候选节点
- 应用选择模式 → INTERSECTS/CONTAINS/CENTER
- 批量碰撞检测 → 筛选符合条件的节点
- 批量更新选择状态 → 更新多个节点状态
- 触发重新渲染 → 更新视觉反馈
--
完整代码
选择handler实现
基于之前的事件系统
js
export class SmartSelectionHandler implements EventHandler {
private isSelecting = false;
private selectionStart: { x: number; y: number } | null = null;
private selectionEnd: { x: number; y: number } | null = null;
private isDragging = false;
private dragThreshold = 3;
private selectionMode: SelectionMode = SelectionMode.INTERSECTS;
private enableSmartPriority = true;
canHandle(event: BaseEvent): boolean {
return event.type.startsWith("mouse.") || event.type.startsWith("key.");
}
handle(event: BaseEvent, context: EventContext): EventResult {
if (event.type.startsWith("mouse.")) return this.handleMouseEvent(event as MouseEvent, context);
if (event.type.startsWith("key.")) return this.handleKeyboardEvent(event as KeyboardEvent);
return { handled: false };
}
private handleMouseEvent(event: MouseEvent, context: EventContext): EventResult {
switch (event.type) {
case "mouse.down": return this.handleMouseDown(event, context);
case "mouse.move": return this.handleMouseMove(event, context);
case "mouse.up": return this.handleMouseUp();
}
return { handled: false };
}
private handleMouseDown(event: MouseEvent, context: EventContext): EventResult {
const nativeEvent = event.nativeEvent as globalThis.MouseEvent;
const isMultiSelect = nativeEvent.ctrlKey || nativeEvent.metaKey;
const worldPoint = coordinateSystemManager.screenToWorld(event.mousePoint.x, event.mousePoint.y);
const allNodes = this.getAllRenderableNodes();
const hitNode = this.enableSmartPriority
? smartHitTest.findBestNodeAtPoint(worldPoint, allNodes, context.canvas)
: this.fallbackHitTest(worldPoint, allNodes);
if (hitNode) {
this.handleNodeSelection(hitNode, isMultiSelect);
return { handled: true, requestRender: true };
} else {
if (!isMultiSelect) selectionStore.clearSelection();
this.startSelection(worldPoint);
return { handled: true, requestRender: true };
}
}
private handleMouseMove(event: MouseEvent): EventResult {
if (!this.isSelecting || !this.selectionStart) return { handled: false };
const worldPoint = coordinateSystemManager.screenToWorld(event.mousePoint.x, event.mousePoint.y);
if (!this.isDragging) {
const dx = Math.abs(worldPoint.x - this.selectionStart.x);
const dy = Math.abs(worldPoint.y - this.selectionStart.y);
if (dx > this.dragThreshold || dy > this.dragThreshold) this.isDragging = true;
else return { handled: true };
}
if (this.isDragging) this.updateSelection(worldPoint);
return { handled: true, requestRender: true };
}
private handleMouseUp(): EventResult {
if (this.isDragging) this.finishSelection();
this.resetSelection();
return { handled: true, requestRender: true };
}
private handleNodeSelection(node: BaseNode, isMultiSelect: boolean): void {
if (isMultiSelect) selectionStore.toggleNode(node.id);
else selectionStore.selectNode(node.id);
}
private startSelection(worldPoint: { x: number; y: number }) {
this.isSelecting = true;
this.selectionStart = { ...worldPoint };
this.selectionEnd = { ...worldPoint };
this.isDragging = false;
}
private updateSelection(worldPoint: { x: number; y: number }) {
this.selectionEnd = { ...worldPoint };
}
private finishSelection() {
if (!this.selectionStart || !this.selectionEnd) return;
const left = Math.min(this.selectionStart.x, this.selectionEnd.x);
const right = Math.max(this.selectionStart.x, this.selectionEnd.x);
const top = Math.min(this.selectionStart.y, this.selectionEnd.y);
const bottom = Math.max(this.selectionStart.y, this.selectionEnd.y);
const selectionRect = { x: left, y: top, width: right - left, height: bottom - top };
const allNodes = this.getAllRenderableNodes();
const selectedNodes = smartHitTest.findNodesInRectangle(
selectionRect,
allNodes,
this.selectionMode,
document.querySelector("canvas") as HTMLCanvasElement
);
selectionStore.clearSelection();
selectedNodes.forEach(node => selectionStore.addToSelection(node.id));
}
private resetSelection() {
this.isSelecting = false;
this.selectionStart = null;
this.selectionEnd = null;
this.isDragging = false;
}
private getAllRenderableNodes(): BaseNode[] {
return Object.keys(elementStore.getElement()).map(nodeId => nodeTree.getNodeById(nodeId)!);
}
private fallbackHitTest(point: { x: number; y: number }, nodes: BaseNode[]): BaseNode | null {
for (let i = nodes.length - 1; i >= 0; i--) {
const node = nodes[i];
if (point.x >= node.x && point.x <= node.x + node.w && point.y >= node.y && point.y <= node.y + node.h)
return node;
}
return null;
}
}
碰撞检测
js
/**
* Figma风格的智能碰撞检测系统
*/
export class SmartHitTest {
private spatialGrid: SpatialGrid;
private viewportGrid: ViewportAwareSpatialGrid;
private performanceMode = false;
private useViewportOptimization = true; // 🎯 新增:启用视口优化
private lastRebuildTime = 0;
private readonly REBUILD_INTERVAL = 5000; // 5秒重建一次空间网格
private currentCanvas: HTMLCanvasElement | null = null;
constructor(cellSize = 200) {
this.spatialGrid = new SpatialGrid(cellSize);
this.viewportGrid = new ViewportAwareSpatialGrid();
}
/**
* 初始化或重建空间网格
*/
initialize(nodes: BaseNode[], canvas?: HTMLCanvasElement): void {
// 🎯 优先使用视口感知网格
if (this.useViewportOptimization && canvas) {
this.currentCanvas = canvas;
const viewportChanged = this.viewportGrid.updateViewport(canvas);
if (viewportChanged) {
this.viewportGrid.rebuild(nodes);
console.log(`🌐 视口网格已重建,包含 ${nodes.length} 个节点`);
return;
}
}
// 备用:传统全局网格
const now = Date.now();
if (
now - this.lastRebuildTime > this.REBUILD_INTERVAL ||
this.spatialGrid.getCandidateNodes({ x: 0, y: 0 }).length === 0
) {
this.spatialGrid.rebuild(nodes);
this.lastRebuildTime = now;
console.log(`🌐 空间网格已重建,包含 ${nodes.length} 个节点`);
}
}
/**
* 快速AABB预检测
*/
private quickAABBTest(
point: { x: number; y: number },
node: BaseNode
): boolean {
// 为旋转节点扩展包围盒
const margin = node.rotation !== 0 ? Math.max(node.w, node.h) * 0.3 : 0;
return (
point.x >= node.x - margin &&
point.x <= node.x + node.w + margin &&
point.y >= node.y - margin &&
point.y <= node.y + node.h + margin
);
}
/**
* 精确的点在矩形内检测(支持旋转)
*/
private isPointInRectangle(
point: { x: number; y: number },
node: BaseNode
): boolean {
const { x, y, w, h, rotation } = node;
if (!rotation || rotation === 0) {
// 快速路径:无旋转的AABB检测
return (
point.x >= x && point.x <= x + w && point.y >= y && point.y <= y + h
);
}
// 精确路径:支持旋转的OBB检测
const centerX = x + w / 2;
const centerY = y + h / 2;
// 将点转换到节点的本地坐标系
const relativeX = point.x - centerX;
const relativeY = point.y - centerY;
// 应用反向旋转矩阵
const cos = Math.cos(-rotation);
const sin = Math.sin(-rotation);
const rotatedX = relativeX * cos - relativeY * sin;
const rotatedY = relativeX * sin + relativeY * cos;
// 在本地坐标系中进行AABB检测
return (
rotatedX >= -w / 2 &&
rotatedX <= w / 2 &&
rotatedY >= -h / 2 &&
rotatedY <= h / 2
);
}
/**
* 计算节点选择优先级
* 基于Figma的智能选择策略
*/
private calculatePriority(
point: { x: number; y: number },
node: BaseNode
): NodePriority {
// 基础优先级
let priority = 0;
// 1. 节点类型优先级
switch (node.type) {
case "text":
priority += 100; // 文本节点最高优先级
break;
case "rectangle":
priority += 50;
break;
default:
priority += 30;
}
// 2. 节点大小优先级(小节点优先)
const area = node.w * node.h;
const areaScore = Math.max(0, 50 - Math.log10(area + 1) * 10);
priority += areaScore;
// 3. 距离中心点的距离(越近优先级越高)
const centerX = node.x + node.w / 2;
const centerY = node.y + node.h / 2;
const distance = Math.sqrt(
Math.pow(point.x - centerX, 2) + Math.pow(point.y - centerY, 2)
);
const distanceScore = Math.max(0, 20 - distance / 10);
priority += distanceScore;
// 4. 边缘优先(点击靠近边缘的小节点优先)
const edgeDistanceX = Math.min(point.x - node.x, node.x + node.w - point.x);
const edgeDistanceY = Math.min(point.y - node.y, node.y + node.h - point.y);
const edgeDistance = Math.min(edgeDistanceX, edgeDistanceY);
if (edgeDistance < 10) {
priority += 15; // 边缘点击奖励
}
return {
node,
priority,
distance,
area,
depth: 0, // TODO: 实现层级深度计算
};
}
/**
* 智能点选检测
* 返回最适合的节点
*/
findBestNodeAtPoint(
point: { x: number; y: number },
allNodes: BaseNode[],
canvas?: HTMLCanvasElement
): BaseNode | null {
this.initialize(allNodes, canvas);
// 第一步:空间分区预筛选
let candidates: BaseNode[];
if (this.useViewportOptimization && this.currentCanvas) {
// 🎯 使用视口感知网格
candidates = this.viewportGrid.getCandidateNodes(point);
console.log(`🎯 视口网格候选: ${candidates.length}/${allNodes.length}`);
} else if (this.performanceMode) {
// 传统空间分区
candidates = this.spatialGrid.getCandidateNodes(point);
} else {
// 全节点遍历
candidates = allNodes;
}
if (candidates.length === 0) {
return null;
}
console.log(`🎯 候选节点: ${candidates.length}/${allNodes.length}`);
// 第二步:AABB预检测
const aabbCandidates = candidates.filter((node) =>
this.quickAABBTest(point, node)
);
if (aabbCandidates.length === 0) {
return null;
}
// 第三步:精确几何检测 + 优先级计算
const validNodes: NodePriority[] = [];
for (const node of aabbCandidates) {
if (this.isPointInRectangle(point, node)) {
const priority = this.calculatePriority(point, node);
validNodes.push(priority);
}
}
if (validNodes.length === 0) {
return null;
}
// 第四步:智能选择最佳节点
validNodes.sort((a, b) => b.priority - a.priority);
const selectedNode = validNodes[0].node;
console.log(
`🏆 选中节点: ${selectedNode.id} (${
selectedNode.type
}) 优先级: ${validNodes[0].priority.toFixed(1)}`
);
return selectedNode;
}
/**
* 矩形选择检测
* 支持多种选择模式
*/
findNodesInRectangle(
selectionRect: { x: number; y: number; width: number; height: number },
allNodes: BaseNode[],
mode: SelectionMode = SelectionMode.INTERSECTS,
canvas?: HTMLCanvasElement
): BaseNode[] {
this.initialize(allNodes, canvas);
// 空间分区预筛选
let candidates: BaseNode[];
if (this.useViewportOptimization && this.currentCanvas) {
// 🎯 使用视口感知网格
candidates = this.viewportGrid.getCandidateNodesInRect(selectionRect);
console.log(
`📦 视口网格框选候选: ${candidates.length}/${allNodes.length}`
);
} else if (this.performanceMode) {
// 传统空间分区
candidates = this.spatialGrid.getCandidateNodesInRect(selectionRect);
} else {
// 全节点遍历
candidates = allNodes;
}
const selectedNodes: BaseNode[] = [];
const left = selectionRect.x;
const right = selectionRect.x + selectionRect.width;
const top = selectionRect.y;
const bottom = selectionRect.y + selectionRect.height;
for (const node of candidates) {
const nodeLeft = node.x;
const nodeRight = node.x + node.w;
const nodeTop = node.y;
const nodeBottom = node.y + node.h;
let isSelected = false;
switch (mode) {
case SelectionMode.CONTAINS:
// 节点完全在选择框内
isSelected =
nodeLeft >= left &&
nodeRight <= right &&
nodeTop >= top &&
nodeBottom <= bottom;
break;
case SelectionMode.CENTER:
// 节点中心点在选择框内
// eslint-disable-next-line no-case-declarations
const centerX = nodeLeft + node.w / 2;
// eslint-disable-next-line no-case-declarations
const centerY = nodeTop + node.h / 2;
isSelected =
centerX >= left &&
centerX <= right &&
centerY >= top &&
centerY <= bottom;
break;
case SelectionMode.INTERSECTS:
default:
// 相交即选中(默认)
isSelected = !(
nodeRight < left ||
nodeLeft > right ||
nodeBottom < top ||
nodeTop > bottom
);
break;
}
if (isSelected) {
selectedNodes.push(node);
}
}
console.log(`📦 框选结果: ${selectedNodes.length} 个节点 (模式: ${mode})`);
return selectedNodes;
}
/**
* 性能模式切换
*/
setPerformanceMode(enabled: boolean): void {
this.performanceMode = enabled;
console.log(`⚡ 性能模式: ${enabled ? "开启" : "关闭"}`);
}
/**
* 视口优化切换
*/
setViewportOptimization(enabled: boolean): void {
this.useViewportOptimization = enabled;
console.log(`🎯 视口优化: ${enabled ? "开启" : "关闭"}`);
}
/**
* 获取网格统计信息
*/
getGridStats() {
return {
viewportGrid: this.viewportGrid.getStats(),
spatialGrid: {
useViewportOptimization: this.useViewportOptimization,
performanceMode: this.performanceMode,
},
};
}
/**
* 添加节点到空间网格
*/
addNode(node: BaseNode): void {
this.spatialGrid.addNode(node);
}
/**
* 从空间网格移除节点
*/
removeNode(node: BaseNode): void {
this.spatialGrid.removeNode(node);
}
}
网格计算
js
/**
* 视口感知的空间网格系统
* 🎯 核心优化:只为当前视口区域维护网格,显著减少内存和计算开销
*/
export class ViewportAwareSpatialGrid {
private grid: Map<string, BaseNode[]> = new Map();
private currentViewport: ViewportInfo | null = null;
private adaptiveCellSize: number = 200;
private gridBounds: {
left: number;
top: number;
right: number;
bottom: number;
} | null = null;
// 性能统计
private stats = {
totalCells: 0,
activeCells: 0,
memoryEfficiency: 0,
lastRebuildTime: 0,
rebuildCount: 0,
};
/**
* 更新视口信息,智能决定是否重建网格
*/
updateViewport(canvas: HTMLCanvasElement): boolean {
const newViewport = this.calculateCurrentViewport(canvas);
if (this.shouldRebuildGrid(newViewport)) {
console.log("🔄 重建视口网格:", {
oldZoom: this.currentViewport?.zoomLevel || 0,
newZoom: newViewport.zoomLevel,
oldBounds: this.gridBounds,
newBounds: newViewport.visibleBounds,
});
this.rebuildForViewport(newViewport);
return true;
}
return false;
}
/**
* 计算当前视口信息
*/
private calculateCurrentViewport(canvas: HTMLCanvasElement): ViewportInfo {
const rect = canvas.getBoundingClientRect();
const canvasWidth = rect.width;
const canvasHeight = rect.height;
// 计算视口四个角的世界坐标
const topLeft = coordinateSystemManager.screenToWorld(0, 0);
const bottomRight = coordinateSystemManager.screenToWorld(
canvasWidth,
canvasHeight
);
// 获取当前缩放级别
const zoomLevel = this.getCurrentZoomLevel();
return {
visibleBounds: {
left: topLeft.x,
top: topLeft.y,
right: bottomRight.x,
bottom: bottomRight.y,
},
zoomLevel,
canvasSize: {
width: canvasWidth,
height: canvasHeight,
},
};
}
/**
* 获取当前缩放级别
*/
private getCurrentZoomLevel(): number {
const viewState = coordinateSystemManager.getViewState();
// 从变换矩阵中提取缩放比例
return Math.sqrt(viewState.matrix[0] ** 2 + viewState.matrix[1] ** 2);
}
/**
* 智能判断是否需要重建网格
*/
private shouldRebuildGrid(newViewport: ViewportInfo): boolean {
if (!this.currentViewport || !this.gridBounds) {
return true; // 首次初始化
}
// 1. 缩放变化检测
const zoomChange = Math.abs(
newViewport.zoomLevel - this.currentViewport.zoomLevel
);
const zoomThreshold = 0.3; // 缩放变化30%时重建
if (zoomChange / this.currentViewport.zoomLevel > zoomThreshold) {
console.log(`🔍 缩放变化触发重建: ${zoomChange.toFixed(2)}`);
return true;
}
// 2. 视口移动检测
const bounds = newViewport.visibleBounds;
const currentBounds = this.gridBounds;
// 检查视口是否移出当前网格范围
const margin = this.adaptiveCellSize * 2; // 提前2个格子的缓冲区
if (
bounds.left < currentBounds.left + margin ||
bounds.right > currentBounds.right - margin ||
bounds.top < currentBounds.top + margin ||
bounds.bottom > currentBounds.bottom - margin
) {
console.log("📐 视口移出网格范围,触发重建");
return true;
}
// 3. 网格利用率检测
const utilization =
this.stats.activeCells / Math.max(this.stats.totalCells, 1);
if (utilization < 0.2) {
// 利用率低于20%时重建
console.log(`📊 网格利用率过低: ${(utilization * 100).toFixed(1)}%`);
return true;
}
// 4. 时间间隔检测(防止频繁重建)
const timeSinceLastRebuild = Date.now() - this.stats.lastRebuildTime;
if (timeSinceLastRebuild < 1000) {
// 1秒内不重复重建
return false;
}
return false;
}
/**
* 为新视口重建网格
*/
private rebuildForViewport(viewport: ViewportInfo): void {
const startTime = performance.now();
// 清空现有网格
this.grid.clear();
// 根据缩放级别调整网格大小
this.adaptiveCellSize = this.getAdaptiveCellSize(viewport.zoomLevel);
// 扩展视口边界,包含缓冲区
const buffer = this.adaptiveCellSize * 3; // 3个格子的缓冲区
this.gridBounds = {
left: viewport.visibleBounds.left - buffer,
top: viewport.visibleBounds.top - buffer,
right: viewport.visibleBounds.right + buffer,
bottom: viewport.visibleBounds.bottom + buffer,
};
// 计算网格尺寸统计
const gridWidth = this.gridBounds.right - this.gridBounds.left;
const gridHeight = this.gridBounds.bottom - this.gridBounds.top;
const cols = Math.ceil(gridWidth / this.adaptiveCellSize);
const rows = Math.ceil(gridHeight / this.adaptiveCellSize);
// 更新统计信息
this.stats.totalCells = cols * rows;
this.stats.activeCells = 0;
this.stats.lastRebuildTime = Date.now();
this.stats.rebuildCount++;
// 保存当前视口
this.currentViewport = viewport;
const rebuildTime = performance.now() - startTime;
console.log("🔄 视口网格重建完成:", {
cellSize: this.adaptiveCellSize,
totalCells: this.stats.totalCells,
gridSize: `${cols}x${rows}`,
rebuildTime: `${rebuildTime.toFixed(2)}ms`,
rebuildCount: this.stats.rebuildCount,
});
}
/**
* 根据缩放级别计算自适应网格大小
*/
private getAdaptiveCellSize(zoomLevel: number): number {
const baseCellSize = 200;
// 缩放越大,网格越小(更精确)
// 缩放越小,网格越大(减少内存)
const adaptedSize = baseCellSize / Math.sqrt(Math.max(zoomLevel, 0.1));
// 限制在合理范围内
return Math.max(50, Math.min(500, adaptedSize));
}
/**
* 获取网格单元键(视口相对坐标)
*/
private getCellKey(x: number, y: number): string {
if (!this.gridBounds) return "0,0";
const col = Math.floor((x - this.gridBounds.left) / this.adaptiveCellSize);
const row = Math.floor((y - this.gridBounds.top) / this.adaptiveCellSize);
return `${col},${row}`;
}
/**
* 检查点是否在当前网格范围内
*/
private isPointInGrid(x: number, y: number): boolean {
if (!this.gridBounds) return false;
return (
x >= this.gridBounds.left &&
x <= this.gridBounds.right &&
y >= this.gridBounds.top &&
y <= this.gridBounds.bottom
);
}
/**
* 添加节点到网格
*/
addNode(node: BaseNode): void {
// 只添加在视口范围内的节点
if (!this.isNodeInViewport(node)) {
return;
}
const cells = this.getCellsForNode(node);
let added = false;
cells.forEach((cellKey) => {
if (!this.grid.has(cellKey)) {
this.grid.set(cellKey, []);
this.stats.activeCells++;
}
this.grid.get(cellKey)!.push(node);
added = true;
});
// 更新内存效率统计
if (added) {
this.stats.memoryEfficiency =
this.stats.activeCells / this.stats.totalCells;
}
}
/**
* 检查节点是否在视口范围内
*/
private isNodeInViewport(node: BaseNode): boolean {
if (!this.gridBounds) return false;
return !(
node.x + node.w < this.gridBounds.left ||
node.x > this.gridBounds.right ||
node.y + node.h < this.gridBounds.top ||
node.y > this.gridBounds.bottom
);
}
/**
* 获取节点覆盖的网格单元
*/
private getCellsForNode(node: BaseNode): string[] {
if (!this.gridBounds) return [];
const cells: string[] = [];
const left = Math.floor(
(node.x - this.gridBounds.left) / this.adaptiveCellSize
);
const right = Math.floor(
(node.x + node.w - this.gridBounds.left) / this.adaptiveCellSize
);
const top = Math.floor(
(node.y - this.gridBounds.top) / this.adaptiveCellSize
);
const bottom = Math.floor(
(node.y + node.h - this.gridBounds.top) / this.adaptiveCellSize
);
for (let col = left; col <= right; col++) {
for (let row = top; row <= bottom; row++) {
cells.push(`${col},${row}`);
}
}
return cells;
}
/**
* 获取点附近的候选节点
*/
getCandidateNodes(point: { x: number; y: number }): BaseNode[] {
if (!this.isPointInGrid(point.x, point.y)) {
return []; // 点不在视口网格范围内
}
const cellKey = this.getCellKey(point.x, point.y);
return this.grid.get(cellKey) || [];
}
/**
* 获取矩形区域内的候选节点
*/
getCandidateNodesInRect(rect: {
x: number;
y: number;
width: number;
height: number;
}): BaseNode[] {
if (!this.gridBounds) return [];
const nodes = new Set<BaseNode>();
const leftCol = Math.floor(
(rect.x - this.gridBounds.left) / this.adaptiveCellSize
);
const rightCol = Math.floor(
(rect.x + rect.width - this.gridBounds.left) / this.adaptiveCellSize
);
const topRow = Math.floor(
(rect.y - this.gridBounds.top) / this.adaptiveCellSize
);
const bottomRow = Math.floor(
(rect.y + rect.height - this.gridBounds.top) / this.adaptiveCellSize
);
for (let col = leftCol; col <= rightCol; col++) {
for (let row = topRow; row <= bottomRow; row++) {
const cellKey = `${col},${row}`;
const cellNodes = this.grid.get(cellKey);
if (cellNodes) {
cellNodes.forEach((node) => nodes.add(node));
}
}
}
return Array.from(nodes);
}
/**
* 重建网格(用于节点变化时)
*/
rebuild(nodes: BaseNode[]): void {
if (!this.currentViewport) return;
this.grid.clear();
this.stats.activeCells = 0;
nodes.forEach((node) => this.addNode(node));
console.log("🔄 网格重建完成:", {
totalNodes: nodes.length,
gridNodes: Array.from(this.grid.values()).flat().length,
activeCells: this.stats.activeCells,
memoryEfficiency: `${(this.stats.memoryEfficiency * 100).toFixed(1)}%`,
});
}
/**
* 清空网格
*/
clear(): void {
this.grid.clear();
this.stats.activeCells = 0;
this.stats.memoryEfficiency = 0;
}
/**
* 获取性能统计
*/
getStats() {
return {
...this.stats,
currentCellSize: this.adaptiveCellSize,
currentZoom: this.currentViewport?.zoomLevel || 0,
gridRange: this.gridBounds,
};
}
}