【图形编辑器架构】🧠 Figma 风格智能选择工具实现原理【猜测】

上一篇我们讲解了实现无限画布、标尺、网格绘制,本篇我们讲解下类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. 数据流与执行流程

点选操作流程

  1. 用户点击 → 获取鼠标坐标
  2. 坐标转换 → 屏幕坐标转世界坐标
  3. 视口网格查找 → 获取候选节点列表
  4. AABB预检测 → 快速排除明显不符合的节点
  5. 精确碰撞检测 → 处理旋转、圆角等复杂形状
  6. 优先级计算 → 多因子评分排序
  7. 选择最佳节点 → 返回得分最高的节点
  8. 更新选择状态 → 更新UI状态
  9. 触发重新渲染 → 更新视觉反馈

框选操作流程

  1. 开始拖拽 → 记录起始点
  2. 拖拽阈值检测 → 避免误触发框选
  3. 创建选择框 → 显示选择框UI
  4. 实时更新选择框 → 跟随鼠标移动
  5. 视口网格查找 → 获取区域内候选节点
  6. 应用选择模式 → INTERSECTS/CONTAINS/CENTER
  7. 批量碰撞检测 → 筛选符合条件的节点
  8. 批量更新选择状态 → 更新多个节点状态
  9. 触发重新渲染 → 更新视觉反馈

--

完整代码

选择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,
    };
  }
}
相关推荐
天桥下的卖艺者3 小时前
R语言基于shiny开发随机森林预测模型交互式 Web 应用程序(应用程序)
前端·随机森林·r语言·shiny
技术钱3 小时前
vue3 两份json数据对比不同的页面给于颜色标识
前端·vue.js·json
路很长OoO3 小时前
Flutter 插件开发实战:桥接原生 SDK
前端·flutter·harmonyos
技术钱3 小时前
react+andDesign+vite+ts从零搭建后台管理系统(三)-Layout布局
javascript·react.js·ecmascript
郝开3 小时前
7. React组件基础样式控制:行内样式,class类名控制
react.js
开水好喝4 小时前
Code Coverage Part I
前端
DoraBigHead4 小时前
🧭 React 理念:让时间屈服于 UI —— 从同步到可中断的演化之路
前端·javascript·面试
敢敢J的憨憨L5 小时前
GPTL(General Purpose Timing Library)使用教程
java·服务器·前端·c++·轻量级计时工具库
喝拿铁写前端5 小时前
Vue 组件通信的两种世界观:`.sync` 与普通 `props` 到底有什么不同?
前端·vue.js·前端框架