diff --git a/.env.development b/.env.development index df9a476..953bb31 100644 --- a/.env.development +++ b/.env.development @@ -1,3 +1,7 @@ ENV_APP_TITLE=运输控制系统(开发) # ENV_HTTP_BASE=/mocks -ENV_WEBSOCKET_BASE=/ws \ No newline at end of file +ENV_WEBSOCKET_BASE=/ws + +# 开发环境token配置 - 可以手动设置或从另一个项目获取后填入 +ENV_DEV_TOKEN=eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJleHAiOjE3NTAzMzkwMTcsInVzZXJuYW1lIjoiYWRtaW4ifQ.uGWMIPH9-sdyEwr0bQBMKQSTAjYBZhlIVDRHGtheENE +ENV_DEV_TENANT_ID=1000 \ No newline at end of file diff --git a/Canvas绘制技术详解.md b/Canvas绘制技术详解.md new file mode 100644 index 0000000..0a8cf91 --- /dev/null +++ b/Canvas绘制技术详解.md @@ -0,0 +1,440 @@ +# Canvas 2D 绘制技术详解 + +## 📖 概述 + +本文档详细分析场景编辑器中的自定义绘制函数,这些函数基于 **HTML5 Canvas 2D API** 在浏览器页面上绘制各种图形元素(点位、路线、区域、机器人)。 + +## 🎯 核心技术栈 + +### 1. HTML5 Canvas 2D API + +- **技术原理**:Canvas 是 HTML5 提供的位图绘制 API +- **绘制方式**:使用 JavaScript 在 Canvas 画布上逐像素绘制 +- **坐标系统**:左上角为原点 (0,0),X轴向右,Y轴向下 +- **绘制上下文**:通过 `CanvasRenderingContext2D` 对象进行所有绘制操作 + +### 2. Meta2D 引擎集成 + +- **自定义绘制**:通过 `registerCanvasDraw()` 注册自定义绘制函数 +- **图形对象**:每个绘制函数接收 `MapPen` 对象,包含图形的所有属性 +- **渲染时机**:引擎在每次重绘时自动调用对应的绘制函数 + +--- + +## 🎨 绘制函数详细分析 + +### 1. 点位绘制函数 `drawPoint()` + +#### 函数签名和参数 + +```typescript +function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void; +``` + +#### 代码逐行分析 + +```typescript +// 1. 获取全局主题配置 +const theme = sTheme.editor; +``` + +**分析**:从全局主题服务获取编辑器主题配置,用于确定颜色、样式等视觉属性。 + +```typescript +// 2. 从计算属性中提取绘制参数 +const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; +``` + +**分析**: + +- `active`:图形是否处于选中状态 +- `iconSize`:图标大小,重命名为 `r`(半径) +- `fontSize/lineHeight/fontFamily`:文本绘制参数 + +```typescript +// 3. 获取世界坐标系下的矩形区域 +const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; +``` + +**分析**:Meta2D 引擎会自动计算图形在世界坐标系下的实际位置和大小。 + +```typescript +// 4. 获取业务属性 +const { type } = pen.point ?? {}; +const { label = '' } = pen ?? {}; +``` + +```typescript +// 5. 保存当前画布状态 +ctx.save(); +``` + +**分析**:`save()` 保存当前的绘制状态(变换矩阵、样式等),避免影响其他图形。 + +#### 小点位绘制(类型1-9) + +```typescript +switch (type) { + case MapPointType.普通点: + case MapPointType.等待点: + case MapPointType.避让点: + case MapPointType.临时避让点: + // 绘制圆角菱形 + ctx.beginPath(); + ctx.moveTo(x + w / 2 - r, y + r); + ctx.arcTo(x + w / 2, y, x + w - r, y + h / 2 - r, r); + ctx.arcTo(x + w, y + h / 2, x + w / 2 + r, y + h - r, r); + ctx.arcTo(x + w / 2, y + h, x + r, y + h / 2 + r, r); + ctx.arcTo(x, y + h / 2, x + r, y + h / 2 - r, r); + ctx.closePath(); +``` + +**分析**: + +- `beginPath()`:开始新的绘制路径 +- `moveTo()`:移动画笔到起始点 +- `arcTo()`:绘制圆弧连接线,创建圆角效果 +- `closePath()`:闭合路径形成完整图形 + +```typescript +// 填充背景色 +ctx.fillStyle = get(theme, `point-s.fill-${type}`) ?? ''; +ctx.fill(); + +// 绘制边框 +ctx.strokeStyle = get(theme, active ? 'point-s.strokeActive' : 'point-s.stroke') ?? ''; +``` + +**分析**:根据点位类型和激活状态设置不同的填充色和边框色。 + +#### 临时避让点特殊标记 + +```typescript +if (type === MapPointType.临时避让点) { + ctx.lineCap = 'round'; // 设置线条端点为圆形 + ctx.beginPath(); + // 绘制8个短线标记,形成放射状效果 + ctx.moveTo(x + 0.66 * r, y + h / 2 - 0.66 * r); + ctx.lineTo(x + r, y + h / 2 - r); + // ... 其他7个方向的短线 +} +``` + +**分析**:在菱形的8个方向绘制短线,形成特殊的视觉标识。 + +#### 大点位绘制(类型11+) + +```typescript +case MapPointType.电梯点: +case MapPointType.自动门点: +case MapPointType.充电点: +case MapPointType.停靠点: +case MapPointType.动作点: +case MapPointType.禁行点: + ctx.roundRect(x, y, w, h, r); // 绘制圆角矩形 + ctx.strokeStyle = get(theme, active ? 'point-l.strokeActive' : 'point-l.stroke') ?? ''; + ctx.stroke(); +``` + +**分析**:大点位使用圆角矩形,通过 `roundRect()` API 一次性绘制。 + +#### 文本标签绘制 + +```typescript +// 设置文本样式 +ctx.fillStyle = get(theme, 'color') ?? ''; +ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`; +ctx.textAlign = 'center'; // 水平居中 +ctx.textBaseline = 'top'; // 垂直顶部对齐 + +// 在点位上方绘制标签 +ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); +``` + +```typescript +// 恢复画布状态 +ctx.restore(); +``` + +--- + +### 2. 路线绘制函数 `drawLine()` + +#### 核心绘制逻辑 + +```typescript +// 1. 获取路线的两个端点坐标 +const [p1, p2] = pen.calculative?.worldAnchors ?? []; +const { x: x1 = 0, y: y1 = 0 } = p1 ?? {}; +const { x: x2 = 0, y: y2 = 0 } = p2 ?? {}; +``` + +```typescript +// 2. 获取路线属性 +const { type, direction = 1, pass = 0, c1, c2 } = pen.route ?? {}; +const { x: dx1 = 0, y: dy1 = 0 } = c1 ?? {}; // 控制点1偏移 +const { x: dx2 = 0, y: dy2 = 0 } = c2 ?? {}; // 控制点2偏移 +``` + +#### 路线类型绘制 + +```typescript +ctx.moveTo(x1, y1); // 移动到起点 +switch (type) { + case MapRouteType.直线: + ctx.lineTo(x2, y2); // 直接连线到终点 + break; + + case MapRouteType.二阶贝塞尔曲线: + // 使用一个控制点绘制曲线 + ctx.quadraticCurveTo(x1 + dx1 * s, y1 + dy1 * s, x2, y2); + break; + + case MapRouteType.三阶贝塞尔曲线: + // 使用两个控制点绘制更复杂的曲线 + ctx.bezierCurveTo(x1 + dx1 * s, y1 + dy1 * s, x2 + dx2 * s, y2 + dy2 * s, x2, y2); + break; +} +``` + +**贝塞尔曲线原理**: + +- **二阶贝塞尔曲线**:由起点、一个控制点、终点定义的曲线 +- **三阶贝塞尔曲线**:由起点、两个控制点、终点定义的更灵活曲线 +- **数学公式**:基于参数方程计算曲线上的每个点 + +#### 禁行路线绘制 + +```typescript +if (pass === MapRoutePassType.禁行) { + ctx.setLineDash([s * 5]); // 设置虚线样式 +} +ctx.stroke(); // 绘制路线 +``` + +#### 方向箭头绘制 + +```typescript +// 1. 计算箭头角度 +let r = (() => { + switch (type) { + case MapRouteType.直线: + return Math.atan2(y2 - y1, x2 - x1); // 直线的角度 + case MapRouteType.二阶贝塞尔曲线: + // 根据控制点计算切线角度 + return direction < 0 ? Math.atan2(dy1 * s, dx1 * s) : Math.atan2(y2 - y1 - dy1 * s, x2 - x1 - dx1 * s); + // ... + } +})(); +``` + +```typescript +// 2. 移动坐标系到箭头位置 +if (direction < 0) { + ctx.translate(x1, y1); // 反向箭头在起点 +} else { + ctx.translate(x2, y2); // 正向箭头在终点 + r += Math.PI; // 旋转180度 +} + +// 3. 绘制箭头(两条线段形成尖角) +ctx.moveTo(Math.cos(r + Math.PI / 5) * s * 10, Math.sin(r + Math.PI / 5) * s * 10); +ctx.lineTo(0, 0); +ctx.lineTo(Math.cos(r - Math.PI / 5) * s * 10, Math.sin(r - Math.PI / 5) * s * 10); +``` + +**箭头绘制原理**: + +- 使用三角函数计算箭头两条边的端点 +- `Math.PI / 5` (36度) 是箭头的张开角度 +- 通过坐标变换将箭头定位到正确位置和角度 + +--- + +### 3. 区域绘制函数 `drawArea()` + +#### 矩形区域绘制 + +```typescript +// 1. 绘制填充矩形 +ctx.rect(x, y, w, h); // 定义矩形路径 +ctx.fillStyle = get(theme, `area.fill-${type}`) ?? ''; // 设置填充色 +ctx.fill(); // 填充矩形 + +// 2. 绘制边框 +ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? ''; +ctx.stroke(); // 绘制边框 +``` + +#### 区域标签 + +```typescript +// 在区域上方居中显示标签 +ctx.fillStyle = get(theme, 'color') ?? ''; +ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`; +ctx.textAlign = 'center'; +ctx.textBaseline = 'top'; +ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); +``` + +--- + +### 4. 机器人绘制函数 `drawRobot()` + +#### 机器人本体绘制 + +```typescript +const ox = x + w / 2; // 机器人中心X坐标 +const oy = y + h / 2; // 机器人中心Y坐标 + +// 绘制椭圆形机器人 +ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2); +ctx.fillStyle = get(theme, 'robot.fill') ?? ''; +ctx.fill(); +ctx.strokeStyle = get(theme, 'robot.stroke') ?? ''; +ctx.stroke(); +``` + +#### 路径轨迹绘制 + +```typescript +if (path?.length) { + // 设置路径样式 + ctx.strokeStyle = get(theme, 'robot.line') ?? ''; + ctx.lineCap = 'round'; // 圆形线帽 + ctx.lineWidth = s * 4; // 粗线条 + ctx.setLineDash([s * 5, s * 10]); // 虚线样式 + + // 坐标变换:移动到机器人中心并旋转 + ctx.translate(ox, oy); + ctx.rotate((-deg * Math.PI) / 180); + + // 绘制路径线条 + ctx.beginPath(); + ctx.moveTo(0, 0); + path.forEach((d) => ctx.lineTo(d.x * s, d.y * s)); + ctx.stroke(); +} +``` + +#### 路径终点箭头 + +```typescript +// 计算路径最后两个点的方向 +const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; // 最后一个点 +const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; // 倒数第二个点 +const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI; + +// 在路径终点绘制箭头 +ctx.translate(ex1 * s, ey1 * s); +ctx.beginPath(); +ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10); +ctx.lineTo(0, 0); +ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10); +ctx.stroke(); +``` + +--- + +## 🔧 Canvas 2D API 核心方法说明 + +### 路径绘制方法 + +| 方法 | 功能 | 示例 | +| --------------------------------------------- | ------------------ | -------------------- | +| `beginPath()` | 开始新的绘制路径 | 每次绘制新图形前调用 | +| `moveTo(x, y)` | 移动画笔到指定位置 | 设置绘制起点 | +| `lineTo(x, y)` | 画直线到指定位置 | 绘制线段 | +| `arcTo(x1, y1, x2, y2, r)` | 绘制圆弧连接 | 创建圆角效果 | +| `quadraticCurveTo(cpx, cpy, x, y)` | 二阶贝塞尔曲线 | 简单曲线 | +| `bezierCurveTo(cp1x, cp1y, cp2x, cp2y, x, y)` | 三阶贝塞尔曲线 | 复杂曲线 | +| `rect(x, y, w, h)` | 矩形路径 | 绘制矩形 | +| `ellipse(x, y, rx, ry, rotation, start, end)` | 椭圆路径 | 绘制椭圆/圆形 | +| `closePath()` | 闭合当前路径 | 连接起点和终点 | + +### 样式设置方法 + +| 属性/方法 | 功能 | 示例 | +| -------------------- | ---------------- | ------------------------------- | +| `fillStyle` | 设置填充颜色 | `ctx.fillStyle = '#ff0000'` | +| `strokeStyle` | 设置描边颜色 | `ctx.strokeStyle = '#0000ff'` | +| `lineWidth` | 设置线条宽度 | `ctx.lineWidth = 2` | +| `lineCap` | 设置线条端点样式 | `'round'`, `'square'`, `'butt'` | +| `setLineDash([...])` | 设置虚线样式 | `ctx.setLineDash([5, 5])` | + +### 变换方法 + +| 方法 | 功能 | 说明 | +| -------------------------------- | ------------ | -------------- | +| `translate(x, y)` | 平移坐标系 | 移动原点位置 | +| `rotate(angle)` | 旋转坐标系 | 按弧度旋转 | +| `setTransform(a, b, c, d, e, f)` | 重置变换矩阵 | 恢复标准坐标系 | + +### 状态管理方法 + +| 方法 | 功能 | 说明 | +| ----------- | ------------ | ---------- | +| `save()` | 保存当前状态 | 压入状态栈 | +| `restore()` | 恢复之前状态 | 弹出状态栈 | + +--- + +## 🎯 绘制流程总结 + +### 1. 标准绘制流程 + +```typescript +function customDraw(ctx: CanvasRenderingContext2D, pen: MapPen): void { + // 1. 保存画布状态 + ctx.save(); + + // 2. 提取绘制参数 + const { x, y, width, height } = pen.calculative?.worldRect ?? {}; + + // 3. 设置样式属性 + ctx.fillStyle = '填充色'; + ctx.strokeStyle = '边框色'; + + // 4. 创建绘制路径 + ctx.beginPath(); + // ... 具体绘制操作 + + // 5. 执行绘制 + ctx.fill(); // 填充 + ctx.stroke(); // 描边 + + // 6. 恢复画布状态 + ctx.restore(); +} +``` + +### 2. 性能优化要点 + +- **状态管理**:及时调用 `save()` 和 `restore()` 避免状态污染 +- **路径复用**:合理使用 `beginPath()` 清除之前的路径 +- **批量绘制**:同类型图形可以合并绘制操作 +- **避免重复计算**:缓存复杂的数学计算结果 + +--- + +## 📊 技术优势 + +### 1. Canvas 2D 的优势 + +- **高性能**:直接操作像素,渲染速度快 +- **灵活性**:可以绘制任意复杂的图形 +- **交互性**:支持鼠标事件检测和处理 +- **兼容性**:现代浏览器完全支持 + +### 2. 自定义绘制的优势 + +- **个性化**:完全定制化的视觉效果 +- **主题支持**:动态切换颜色主题 +- **状态反馈**:不同状态显示不同样式 +- **扩展性**:易于添加新的图形类型 + +--- + +## 🔚 结语 + +本场景编辑器通过 Canvas 2D API 实现了丰富的图形绘制功能,每个绘制函数都经过精心设计,既保证了视觉效果,又兼顾了性能表现。理解这些绘制原理对于进一步扩展和优化编辑器功能具有重要意义。 diff --git a/Meta2D引擎作用详解.md b/Meta2D引擎作用详解.md new file mode 100644 index 0000000..a68e4c2 --- /dev/null +++ b/Meta2D引擎作用详解.md @@ -0,0 +1,600 @@ +# Meta2D引擎作用详解 + +## 🤔 您的疑问:Meta2D到底做了什么? + +您的观察很准确!确实,具体的绘制操作都是通过HTML5 Canvas原生API实现的。那么Meta2D引擎到底在做什么呢? + +**简单类比**:如果说Canvas API是"画笔和颜料",那么Meta2D就是"画师的大脑和手" - 它决定什么时候画、画在哪里、画什么样式,以及如何响应用户的操作。 + +--- + +## 🎯 Meta2D引擎的核心作用 + +Meta2D引擎并不是替代Canvas API,而是在Canvas API之上构建了一个完整的**图形管理和渲染框架**。它的主要作用包括: + +### 1. 🎨 渲染管理系统 + +```typescript +// 我们只需要注册绘制函数 +this.registerCanvasDraw({ + point: drawPoint, + line: drawLine, + area: drawArea, + robot: drawRobot, +}); + +// Meta2D会自动调用这些函数 +``` + +**Meta2D负责**: + +- **何时渲染**:自动检测数据变化,决定何时重绘 +- **渲染顺序**:管理图层顺序,确保正确的绘制层级 +- **性能优化**:只重绘需要更新的部分,避免全量重绘 +- **调用时机**:在正确的时机调用我们的绘制函数 + +### 2. 🎮 图形对象管理 + +```typescript +// 创建一个点位 - 我们只需要定义数据结构 +const pen: MapPen = { + id: 'point1', + name: 'point', + x: 100, + y: 100, + width: 24, + height: 24, + point: { type: MapPointType.普通点 }, +}; + +// Meta2D会管理这个对象的整个生命周期 +await this.addPen(pen, false, true, true); +``` + +**Meta2D负责**: + +- **对象存储**:维护所有图形对象的数据结构 +- **坐标转换**:从逻辑坐标转换为屏幕坐标 +- **状态管理**:追踪每个对象的状态(选中、激活、可见等) +- **生命周期**:管理对象的创建、更新、删除 + +### 3. 🖱️ 事件处理系统 + +```typescript +// 我们只需要监听高级事件 +this.on('click', (e, data) => { + // Meta2D已经处理了鼠标点击的复杂逻辑 + console.log('点击了图形:', data); +}); +``` + +**Meta2D负责**: + +- **事件捕获**:监听原生DOM事件(mousedown、mousemove、mouseup等) +- **坐标转换**:将屏幕坐标转换为画布坐标 +- **碰撞检测**:判断点击了哪个图形对象 +- **事件分发**:将事件分发给正确的处理器 + +### 4. 📐 坐标系统管理 + +```typescript +// 我们在绘制函数中使用的坐标 +const { x, y, width, height } = pen.calculative?.worldRect ?? {}; +``` + +**Meta2D负责**: + +- **坐标计算**:自动计算`worldRect`(世界坐标) +- **缩放处理**:处理画布缩放时的坐标转换 +- **视口管理**:管理可视区域和裁剪 +- **变换矩阵**:处理复杂的坐标变换 + +--- + +## 🔄 Meta2D的工作流程 + +### 1. 初始化阶段 + +```typescript +export class EditorService extends Meta2d { + constructor(container: HTMLDivElement) { + // 1. 创建Meta2D实例,传入配置 + super(container, EDITOR_CONFIG); + + // 2. 注册自定义绘制函数 + this.#register(); + + // 3. 监听事件 + this.on('*', (e, v) => this.#listen(e, v)); + } +} +``` + +### 2. 图形创建阶段 + +```typescript +// 用户调用 +await this.addPoint({ x: 100, y: 100 }, MapPointType.普通点); + +// Meta2D内部流程: +// 1. 创建图形对象数据结构 +// 2. 分配唯一ID +// 3. 计算坐标和尺寸 +// 4. 添加到图形列表 +// 5. 触发重绘 +``` + +### 3. 渲染阶段 + +```typescript +// Meta2D的渲染循环(简化版) +function render() { + // 1. 清空画布 + ctx.clearRect(0, 0, canvas.width, canvas.height); + + // 2. 遍历所有图形对象 + for (const pen of this.store.data.pens) { + // 3. 计算世界坐标 + this.updateWorldRect(pen); + + // 4. 调用对应的绘制函数 + const drawFn = this.canvasDrawMap[pen.name]; // 获取我们注册的绘制函数 + if (drawFn) { + drawFn(ctx, pen); // 调用 drawPoint、drawLine 等 + } + } +} +``` + +### 4. 事件处理阶段 + +```typescript +// 用户点击画布 +canvas.addEventListener('click', (e) => { + // Meta2D内部处理: + // 1. 获取点击坐标 + const point = { x: e.offsetX, y: e.offsetY }; + + // 2. 转换为画布坐标 + const worldPoint = this.screenToWorld(point); + + // 3. 碰撞检测 - 判断点击了哪个图形 + const hitPen = this.hitTest(worldPoint); + + // 4. 触发相应事件 + if (hitPen) { + this.emit('click', e, hitPen); + } +}); +``` + +--- + +## 🎯 Meta2D的核心价值 + +### 1. 抽象层次提升 + +```typescript +// 没有Meta2D,我们需要手动处理: +canvas.addEventListener('mousedown', (e) => { + // 计算点击坐标 + // 检测点击了哪个图形 + // 处理拖拽逻辑 + // 重绘画布 + // ... 大量底层代码 +}); + +// 有了Meta2D,我们只需要: +this.on('mousedown', (e, pen) => { + // 直接处理业务逻辑 + console.log('点击了图形:', pen.id); +}); +``` + +### 2. 数据驱动渲染 + +```typescript +// 数据变化自动触发重绘 +this.setValue({ id: 'point1', x: 200 }); // Meta2D会自动重绘 +``` + +### 3. 复杂交互支持 + +```typescript +// 选择、拖拽、缩放、旋转等复杂交互 +this.active(['point1', 'point2']); // 多选 +this.inactive(); // 取消选择 +this.delete([pen]); // 删除图形 +``` + +### 4. 性能优化 + +- **脏矩形重绘**:只重绘变化的区域 +- **离屏渲染**:复杂图形使用离屏Canvas +- **层级管理**:合理的图层分离 +- **事件优化**:高效的碰撞检测算法 + +--- + +## 🏗️ 架构分层对比 + +### 传统Canvas开发 + +``` +┌─────────────────────┐ +│ 业务逻辑层 │ +├─────────────────────┤ +│ 手动管理层 │ ← 需要自己实现 +│ (对象管理/事件/渲染) │ +├─────────────────────┤ +│ Canvas 2D API │ +├─────────────────────┤ +│ 浏览器引擎 │ +└─────────────────────┘ +``` + +### 使用Meta2D + +``` +┌─────────────────────┐ +│ 业务逻辑层 │ ← 我们专注于这里 +├─────────────────────┤ +│ Meta2D 引擎 │ ← 引擎处理复杂逻辑 +├─────────────────────┤ +│ Canvas 2D API │ ← 底层绘制API +├─────────────────────┤ +│ 浏览器引擎 │ +└─────────────────────┘ +``` + +--- + +## 🎨 实际代码示例 + +### 没有Meta2D的代码(复杂) + +```typescript +class ManualCanvas { + private pens: MapPen[] = []; + private selectedPens: MapPen[] = []; + + constructor(private canvas: HTMLCanvasElement) { + this.canvas.addEventListener('click', this.onClick.bind(this)); + this.canvas.addEventListener('mousemove', this.onMouseMove.bind(this)); + // ... 更多事件监听 + } + + onClick(e: MouseEvent) { + const rect = this.canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + // 手动碰撞检测 + for (const pen of this.pens) { + if (this.isPointInPen(x, y, pen)) { + this.selectPen(pen); + this.render(); // 手动重绘 + break; + } + } + } + + render() { + const ctx = this.canvas.getContext('2d')!; + ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); + + // 手动绘制每个图形 + for (const pen of this.pens) { + this.drawPen(ctx, pen); + } + } + + // ... 大量的手动管理代码 +} +``` + +### 使用Meta2D的代码(简洁) + +```typescript +class EditorService extends Meta2d { + constructor(container: HTMLDivElement) { + super(container, EDITOR_CONFIG); + + // 注册绘制函数 + this.registerCanvasDraw({ point: drawPoint }); + + // 监听事件 + this.on('click', (e, pen) => { + // 直接处理业务逻辑 + this.handlePenClick(pen); + }); + } + + async addPoint(p: Point, type: MapPointType) { + const pen: MapPen = { + // ... 定义数据结构 + }; + + // Meta2D自动处理渲染 + await this.addPen(pen); + } +} +``` + +--- + +## 🎯 总结 + +Meta2D引擎的作用就像是一个**智能管家**: + +1. **您专注于业务**:定义数据结构和绘制逻辑 +2. **引擎处理细节**:坐标转换、事件处理、渲染优化 +3. **原生API执行**:最终通过Canvas API完成绘制 + +这种分工让您可以: + +- 🎯 **专注业务逻辑**:不需要处理复杂的底层细节 +- 🚀 **提高开发效率**:大量重复的工作由引擎完成 +- 🎨 **获得更好的性能**:引擎内置了各种优化策略 +- 🔧 **更容易维护**:清晰的架构分层 + +**Meta2D = 图形管理框架 + 事件处理系统 + 渲染优化引擎** + +它不是替代Canvas API,而是在Canvas API之上构建了一个完整的企业级图形编辑解决方案! + +--- + +## 📱 项目中的实际应用 + +### 1. 响应式数据流集成 + +```typescript +export class EditorService extends Meta2d { + // Meta2D处理底层变化,我们用RxJS处理业务逻辑 + readonly #change$$ = new Subject(); + + public readonly current = useObservable( + this.#change$$.pipe( + debounceTime(100), + map(() => clone(this.store.active?.[0])), + ), + ); + + // Meta2D的事件 → RxJS流 → Vue响应式数据 + #listen(e: unknown, v: any) { + switch (e) { + case 'add': + case 'delete': + case 'update': + this.#change$$.next(true); // 通知数据变化 + break; + } + } +} +``` + +### 2. 复杂业务逻辑简化 + +```typescript +// 创建区域时的智能关联 +public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { + // Meta2D自动处理选中状态 + const selected = this.store.active; + + // 根据区域类型自动关联相关元素 + switch (type) { + case MapAreaType.库区: + selected?.filter(({ point }) => point?.type === MapPointType.动作点) + .forEach(({ id }) => points.push(id!)); + break; + case MapAreaType.互斥区: + selected?.filter(({ point }) => point?.type).forEach(({ id }) => points.push(id!)); + selected?.filter(({ route }) => route?.type).forEach(({ id }) => routes.push(id!)); + break; + } + + // Meta2D自动处理图形创建和渲染 + const area = await this.addPen(pen, true, true, true); + this.bottom(area); // 自动层级管理 +} +``` + +### 3. 主题系统集成 + +```typescript +// 监听主题变化,Meta2D自动重绘 +watch( + () => sTheme.theme, + (theme) => { + this.setTheme(theme); // Meta2D内置主题系统 + + // 重新应用主题到自定义绘制 + this.find('point').forEach((pen) => { + if (pen.point?.type >= 10) { + this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type)); + } + }); + + this.render(); // 触发重绘 + }, + { immediate: true }, +); +``` + +### 4. 实时数据更新 + +```typescript +// 机器人实时位置更新 +public refreshRobot(id: RobotInfo['id'], info: Partial): void { + const { x: cx = 37, y: cy = 37, active, angle, path: points } = info; + + // Meta2D自动处理坐标转换和重绘 + this.setValue({ + id, + x: cx - 37, + y: cy - 37, + rotate: angle, + robot: { ...robot, active, path }, + visible: true + }, { render: true, history: false, doEvent: false }); +} +``` + +### 5. 事件系统的实际使用 + +```typescript +constructor(container: HTMLDivElement) { + super(container, EDITOR_CONFIG); + + // Meta2D统一事件处理 + this.on('*', (e, v) => this.#listen(e, v)); + + // 具体事件映射到业务逻辑 + #listen(e: unknown, v: any) { + switch (e) { + case 'click': + case 'mousedown': + case 'mouseup': + // 转换为响应式数据流 + this.#mouse$$.next({ type: e, value: pick(v, 'x', 'y') }); + break; + case 'active': + case 'inactive': + // 选中状态变化 + this.#change$$.next(false); + break; + } + } +} +``` + +--- + +## 🎯 如果没有Meta2D会怎样? + +假设我们要自己实现同样的功能,需要处理的复杂度: + +### 1. 坐标系统管理 + +```typescript +// 需要手动处理缩放、平移、坐标转换 +class CoordinateSystem { + private scale = 1; + private offsetX = 0; + private offsetY = 0; + + screenToWorld(screenPoint: Point): Point { + return { + x: (screenPoint.x - this.offsetX) / this.scale, + y: (screenPoint.y - this.offsetY) / this.scale, + }; + } + + worldToScreen(worldPoint: Point): Point { + return { + x: worldPoint.x * this.scale + this.offsetX, + y: worldPoint.y * this.scale + this.offsetY, + }; + } + + // 还需要处理矩阵变换、旋转等复杂情况... +} +``` + +### 2. 碰撞检测系统 + +```typescript +// 需要为每种图形实现碰撞检测 +class HitTest { + hitTestPoint(point: Point, pen: MapPen): boolean { + const rect = this.getPenRect(pen); + return point.x >= rect.x && point.x <= rect.x + rect.width && point.y >= rect.y && point.y <= rect.y + rect.height; + } + + hitTestLine(point: Point, pen: MapPen): boolean { + // 需要实现点到线段的距离计算 + // 还要考虑贝塞尔曲线的复杂情况 + } + + hitTestArea(point: Point, pen: MapPen): boolean { + // 矩形碰撞检测 + } + + // 还需要处理旋转、缩放后的碰撞检测... +} +``` + +### 3. 渲染管理系统 + +```typescript +// 需要手动管理渲染队列和优化 +class RenderManager { + private dirtyRects: Rect[] = []; + private renderQueue: MapPen[] = []; + + markDirty(pen: MapPen) { + this.dirtyRects.push(this.getPenRect(pen)); + this.renderQueue.push(pen); + } + + render() { + // 计算需要重绘的区域 + const mergedRect = this.mergeDirtyRects(); + + // 清空脏区域 + this.ctx.clearRect(mergedRect.x, mergedRect.y, mergedRect.width, mergedRect.height); + + // 重绘相关图形 + for (const pen of this.getIntersectingPens(mergedRect)) { + this.drawPen(pen); + } + } + + // 大量的优化逻辑... +} +``` + +**这些复杂的底层逻辑,Meta2D都已经帮我们处理好了!** + +--- + +## 🚀 Meta2D带来的开发体验提升 + +### 开发效率对比 + +| 功能 | 纯Canvas开发 | 使用Meta2D | +| -------------------- | ------------ | ----------------------------- | +| 创建一个可点击的图形 | ~100行代码 | ~10行代码 | +| 实现拖拽功能 | ~200行代码 | 内置支持 | +| 多选和批量操作 | ~300行代码 | `this.active([...])` | +| 撤销重做 | ~500行代码 | 内置支持 | +| 图形层级管理 | ~100行代码 | `this.top()`, `this.bottom()` | +| 响应式数据绑定 | 需要自己实现 | 内置事件系统 | + +### 维护成本对比 + +| 场景 | 纯Canvas | Meta2D | +| -------------- | ---------------- | ------------------ | +| 添加新图形类型 | 修改多个系统 | 添加绘制函数即可 | +| 性能优化 | 需要深度优化 | 引擎已优化 | +| Bug修复 | 涉及多个底层模块 | 通常只涉及业务逻辑 | +| 功能扩展 | 可能需要重构架构 | 基于现有API扩展 | + +--- + +## 🏆 总结 + +Meta2D引擎就像是为Canvas开发者提供的一个**超级工具箱**: + +- 🎨 **您负责创意**:定义什么样的图形、什么样的交互 +- 🔧 **Meta2D负责实现**:处理所有复杂的底层逻辑 +- 🚀 **Canvas负责绘制**:最终的像素级渲染 + +这种架构让我们的场景编辑器项目能够: + +- ✅ 快速开发复杂的图形编辑功能 +- ✅ 获得企业级的性能和稳定性 +- ✅ 专注于业务逻辑而不是底层实现 +- ✅ 轻松维护和扩展功能 + +**Meta2D不是画笔,而是整个画室的管理系统!** diff --git a/WebSocket增强服务技术设计文档.md b/WebSocket增强服务技术设计文档.md new file mode 100644 index 0000000..a4926f0 --- /dev/null +++ b/WebSocket增强服务技术设计文档.md @@ -0,0 +1,646 @@ +# WebSocket增强服务技术设计文档 + +## 概述 + +本文档详细解释了 `src/services/ws.ts` 的技术设计思路、架构选择和实现细节。这个文件实现了一个增强的WebSocket服务,在保持原有接口不变的前提下,添加了心跳检测、自动重连、错误处理等企业级功能。 + +## 设计目标 + +### 主要目标 + +1. **零侵入性**:业务代码无需修改,完全透明的功能增强 +2. **企业级稳定性**:心跳检测、自动重连、错误恢复 +3. **可配置性**:全局配置,易于调整和优化 +4. **类型安全**:完整的TypeScript类型支持 +5. **内存安全**:正确的资源管理,防止内存泄漏 + +### 兼容性目标 + +- 保持原有 `create(path): Promise` 接口不变 +- 返回标准WebSocket实例,支持所有原生API +- 业务代码中的 `ws.onmessage`, `ws.close()` 等调用完全兼容 + +## 架构设计 + +### 整体架构图 + +``` +┌─────────────────────┐ ┌──────────────────────┐ ┌─────────────────────┐ +│ 业务代码 │ │ EnhancedWebSocket │ │ 原生WebSocket │ +│ │ │ (包装器) │ │ │ +│ ws.onmessage = ... │───▶│ 事件拦截和过滤 │───▶│ 实际网络连接 │ +│ ws.send(data) │ │ 心跳检测逻辑 │ │ │ +│ ws.close() │ │ 重连管理 │ │ │ +└─────────────────────┘ └──────────────────────┘ └─────────────────────┘ + │ + ▼ + ┌──────────────────────┐ + │ WS_CONFIG │ + │ (全局配置) │ + │ - 心跳间隔 │ + │ - 重连策略 │ + │ - 超时设置 │ + └──────────────────────┘ +``` + +### 设计模式选择 + +#### 1. 包装器模式 (Wrapper Pattern) + +```typescript +class EnhancedWebSocket { + private ws: WebSocket; // 包装原生WebSocket +} +``` + +**为什么选择包装器而不是继承?** + +1. **继承的问题**: + + ```typescript + // 继承方式的问题 + class EnhancedWebSocket extends WebSocket { + constructor(url: string) { + super(url); // 连接立即开始,无法在事件处理器设置前进行拦截 + } + } + ``` + +2. **包装器的优势**: + ```typescript + // 包装器方式的优势 + class EnhancedWebSocket { + constructor(path: string, baseUrl: string) { + this.ws = new WebSocket(baseUrl + path); // 控制创建时机 + this.setupHandlers(); // 立即设置我们的处理器 + } + } + ``` + +#### 2. 代理模式 (Proxy Pattern) + +通过getter/setter拦截用户对事件处理器的设置: + +```typescript +get onmessage(): ((event: MessageEvent) => void) | null { + return this.userOnMessage; +} + +set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.userOnMessage = handler; // 保存用户的处理器 + // 我们的处理器已经在构造时设置,会调用用户的处理器 +} +``` + +## 核心技术实现 + +### 1. Class 设计选择 + +#### 为什么使用 Class? + +```typescript +class EnhancedWebSocket { + // 私有状态管理 + private ws: WebSocket; + private path: string; + private heartbeatTimer?: NodeJS.Timeout; + // ... +} +``` + +**选择Class的原因:** + +1. **状态封装**:WebSocket连接需要管理多个状态(连接、定时器、配置等) +2. **方法绑定**:事件处理器需要访问实例状态,Class提供了自然的this绑定 +3. **生命周期管理**:连接的创建、维护、销毁有清晰的生命周期 +4. **类型安全**:TypeScript对Class有更好的类型推导和检查 + +**与函数式方案的对比:** + +```typescript +// 函数式方案的问题 +function createEnhancedWS(path: string) { + let heartbeatTimer: NodeJS.Timeout; + let reconnectTimer: NodeJS.Timeout; + // 需要大量闭包来管理状态,复杂度高 +} + +// Class方案的优势 +class EnhancedWebSocket { + private heartbeatTimer?: NodeJS.Timeout; // 清晰的状态管理 + private reconnectTimer?: NodeJS.Timeout; + // 方法可以直接访问状态 +} +``` + +### 2. Private 成员设计 + +#### 为什么大量使用 private? + +```typescript +class EnhancedWebSocket { + private ws: WebSocket; // 内部WebSocket实例 + private path: string; // 连接路径 + private heartbeatTimer?: NodeJS.Timeout; // 心跳定时器 + private reconnectTimer?: NodeJS.Timeout; // 重连定时器 + private reconnectAttempts: number = 0; // 重连次数 + private isManualClose: boolean = false; // 手动关闭标志 + private isHeartbeatTimeout: boolean = false; // 心跳超时标志 +} +``` + +**Private的重要性:** + +1. **封装原则**:防止外部直接访问和修改内部状态 +2. **API稳定性**:内部实现可以随时重构,不影响公共接口 +3. **状态一致性**:防止外部代码破坏内部状态的一致性 +4. **错误预防**:避免用户误用内部API导致的bug + +**示例对比:** + +```typescript +// 如果没有private,用户可能这样做 +const ws = new EnhancedWebSocket('/test'); +ws.heartbeatTimer = undefined; // 💥 破坏了心跳检测 +ws.reconnectAttempts = -1; // 💥 破坏了重连逻辑 + +// 有了private,这些操作被编译器阻止 +// ✅ 确保了内部状态的安全性 +``` + +### 3. Constructor 设计 + +#### 构造函数的关键作用 + +```typescript +constructor(path: string, baseUrl: string) { + this.path = path; + this.baseUrl = baseUrl; + this.ws = new WebSocket(baseUrl + path); // 创建实际连接 + this.setupHandlers(); // 立即设置事件处理器 +} +``` + +**设计要点:** + +1. **立即执行**:构造时立即创建连接和设置处理器 +2. **状态初始化**:确保所有私有状态都有正确的初始值 +3. **参数验证**:(可以添加)对输入参数进行验证 +4. **最小权限**:只接收必要的参数,其他配置使用全局配置 + +**为什么不延迟创建连接?** + +```typescript +// ❌ 错误方案:延迟创建 +constructor(path: string, baseUrl: string) { + this.path = path; + this.baseUrl = baseUrl; + // 不创建连接,等用户调用connect() +} + +// ✅ 正确方案:立即创建 +constructor(path: string, baseUrl: string) { + // 立即创建,因为原有接口期望构造后就有连接 + this.ws = new WebSocket(baseUrl + path); + this.setupHandlers(); +} +``` + +### 4. Getter/Setter 设计 + +#### 透明的属性代理 + +```typescript +// 只读属性的getter +get readyState(): number { + return this.ws.readyState; // 直接代理到内部WebSocket +} + +get url(): string { + return this.ws.url; +} + +// 可写属性的getter/setter +get binaryType(): BinaryType { + return this.ws.binaryType; +} + +set binaryType(value: BinaryType) { + this.ws.binaryType = value; +} +``` + +**为什么需要这些getter/setter?** + +1. **API兼容性**:用户期望能够访问标准WebSocket的所有属性 +2. **透明代理**:用户感觉在使用标准WebSocket,实际上是我们的增强版本 +3. **状态同步**:确保外部看到的状态与内部WebSocket状态一致 + +#### 事件处理器的特殊getter/setter + +```typescript +// 事件处理器的拦截 +get onmessage(): ((event: MessageEvent) => void) | null { + return this.userOnMessage; // 返回用户设置的处理器 +} + +set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.userOnMessage = handler; // 保存用户的处理器 + // 我们的内部处理器会调用用户的处理器 +} +``` + +**关键设计思路:** + +1. **双层处理**:我们的处理器 + 用户的处理器 +2. **透明性**:用户感觉直接在设置WebSocket的事件处理器 +3. **控制权**:我们先处理(如过滤心跳),再传递给用户 + +### 5. 事件处理架构 + +#### 事件流设计 + +``` +WebSocket原生事件 → 我们的处理器 → 过滤/处理 → 用户的处理器 +``` + +#### 具体实现 + +```typescript +private setupHandlers(): void { + // 1. 设置我们的处理器 + this.ws.onmessage = (event) => { + const messageData = event.data; + + // 2. 我们先处理(心跳检测) + let isHeartbeatResponse = false; + if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + + if (isHeartbeatResponse) { + this.clearHeartbeatTimeout(); // 清除心跳超时 + return; // 不传递给用户 + } + + // 3. 传递给用户的处理器 + if (this.userOnMessage) { + this.userOnMessage(event); + } + }; +} +``` + +**设计优势:** + +1. **消息过滤**:自动过滤心跳消息,用户无感知 +2. **状态管理**:自动处理连接状态变化 +3. **错误恢复**:自动处理连接错误和重连 + +### 6. 定时器管理 + +#### 定时器生命周期管理 + +```typescript +class EnhancedWebSocket { + private heartbeatTimer?: NodeJS.Timeout; // 心跳发送定时器 + private heartbeatTimeoutTimer?: NodeJS.Timeout; // 心跳响应超时定时器 + private reconnectTimer?: NodeJS.Timeout; // 重连定时器 +} +``` + +**为什么需要三个定时器?** + +1. **heartbeatTimer**:定期发送心跳包 +2. **heartbeatTimeoutTimer**:检测心跳响应超时 +3. **reconnectTimer**:延迟重连 + +#### 定时器清理策略 + +```typescript +// 停止心跳检测 +private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; // 重置为undefined + } + this.clearHeartbeatTimeout(); // 同时清理超时检测 +} + +// 清除心跳响应超时检测 +private clearHeartbeatTimeout(): void { + if (this.heartbeatTimeoutTimer) { + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = undefined; // 重置为undefined + } +} +``` + +**内存安全保证:** + +1. **及时清理**:每次停止时都清理定时器 +2. **状态重置**:清理后设置为undefined +3. **多重清理**:在多个关键点都进行清理(连接关闭、手动关闭等) + +### 7. 状态标志设计 + +#### 关键状态标志 + +```typescript +private isManualClose: boolean = false; // 是否手动关闭 +private isHeartbeatTimeout: boolean = false; // 是否心跳超时 +private reconnectAttempts: number = 0; // 重连次数 +``` + +**为什么需要这些标志?** + +1. **区分关闭原因**:手动关闭 vs 异常断开 vs 心跳超时 +2. **重连决策**:根据不同原因决定是否重连 +3. **状态跟踪**:跟踪重连进度和次数 + +#### 状态转换逻辑 + +```typescript +// 心跳超时时 +private startHeartbeatTimeout(): void { + this.heartbeatTimeoutTimer = setTimeout(() => { + this.isHeartbeatTimeout = true; // 设置心跳超时标志 + this.ws.close(1000, 'Heartbeat timeout'); + }, WS_CONFIG.heartbeatTimeout); +} + +// 连接关闭时的决策 +this.ws.onclose = (event) => { + // 如果不是手动关闭,或者是心跳超时导致的关闭,则重连 + if (!this.isManualClose || this.isHeartbeatTimeout) { + this.scheduleReconnect(); + } + + this.isHeartbeatTimeout = false; // 重置标志 +}; +``` + +### 8. addEventListener/removeEventListener 实现 + +#### 为什么需要这些方法? + +```typescript +addEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | AddEventListenerOptions +): void { + this.ws.addEventListener(type, listener, options); +} + +removeEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | EventListenerOptions +): void { + this.ws.removeEventListener(type, listener, options); +} +``` + +**重要性:** + +1. **完整的API兼容性**:某些业务代码可能使用addEventListener而不是onXXX +2. **事件管理**:支持多个监听器 +3. **标准兼容**:遵循WebSocket标准API + +**类型安全:** + +- 使用泛型 `` 确保事件类型正确 +- listener参数的类型根据事件类型自动推导 + +### 9. 心跳检测机制 + +#### 心跳超时检测逻辑 + +```typescript +// 发送心跳时,只在没有超时检测时才设置新的 +this.heartbeatTimer = setInterval(() => { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(WS_CONFIG.heartbeatMessage); + + if (!this.heartbeatTimeoutTimer) { + // 关键:避免重复设置 + this.startHeartbeatTimeout(); + } + } +}, WS_CONFIG.heartbeatInterval); +``` + +**设计要点:** + +1. **避免重复设置**:只有在没有超时检测时才设置新的 +2. **超时逻辑**:设定时间内没收到响应就断开连接 +3. **状态同步**:收到响应时清除超时检测 + +#### 心跳响应处理 + +```typescript +// 检查是否为心跳响应(支持字符串和JSON格式) +let isHeartbeatResponse = false; + +// 1. 检查简单字符串格式 +if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; +} + +// 2. 检查JSON格式 +if (!isHeartbeatResponse && typeof messageData === 'string') { + try { + const data = JSON.parse(messageData); + if (data.type === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + } catch (e) { + // JSON解析失败,不是JSON格式的心跳响应 + } +} +``` + +**兼容性设计**:支持两种心跳响应格式,适应不同的服务器实现。 + +### 10. 重连机制 + +#### 指数退避算法 + +```typescript +private scheduleReconnect(): void { + if (this.isManualClose || this.reconnectAttempts >= WS_CONFIG.maxReconnectAttempts) { + return; + } + + this.reconnectAttempts++; + + // 指数退避重连策略 + const delay = Math.min( + WS_CONFIG.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1), + WS_CONFIG.maxReconnectDelay + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnect(); + }, delay); +} +``` + +**算法解释:** + +- 第1次重连:1000ms 后 +- 第2次重连:2000ms 后 +- 第3次重连:4000ms 后 +- 第4次重连:8000ms 后 +- 第5次重连:16000ms 后(受maxReconnectDelay限制,实际为30000ms) + +**设计考虑:** + +1. **指数退避**:避免对服务器造成压力 +2. **最大延迟限制**:防止延迟过长 +3. **次数限制**:避免无限重连 +4. **服务器友好**:给服务器恢复时间 + +### 11. 类型安全设计 + +#### 严格的类型定义 + +```typescript +// 事件处理器类型 +private userOnMessage: ((event: MessageEvent) => void) | null = null; +private userOnClose: ((event: CloseEvent) => void) | null = null; +private userOnError: ((event: Event) => void) | null = null; +private userOnOpen: ((event: Event) => void) | null = null; +``` + +**类型安全的好处:** + +1. **编译时检查**:在编译时捕获类型错误 +2. **IDE支持**:更好的自动补全和错误提示 +3. **重构安全**:类型系统确保重构的正确性 + +#### 泛型的使用 + +```typescript +addEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | AddEventListenerOptions +): void +``` + +**泛型的价值:** + +- `K extends keyof WebSocketEventMap`:确保事件类型只能是WebSocket支持的类型 +- `ev: WebSocketEventMap[K]`:根据事件类型自动推导事件对象类型 + +### 12. 资源管理 + +#### 完整的清理机制 + +```typescript +close(code?: number, reason?: string): void { + console.log(`手动关闭WebSocket: ${this.path}`); + this.isManualClose = true; + this.isHeartbeatTimeout = false; // 重置心跳超时标志 + this.stopHeartbeat(); // 清理心跳定时器 + this.clearReconnectTimer(); // 清理重连定时器 + this.ws.close(code, reason); // 关闭实际连接 +} +``` + +**资源清理的重要性:** + +1. **内存泄漏预防**:确保所有定时器都被清理 +2. **状态一致性**:重置所有状态标志 +3. **优雅关闭**:按正确顺序清理资源 + +## 配置设计 + +### 全局配置对象 + +```typescript +const WS_CONFIG = { + heartbeatInterval: 3000, // 心跳间隔 + heartbeatTimeout: 5000, // 心跳响应超时时间 + maxReconnectAttempts: 5, // 最大重连次数 + reconnectBaseDelay: 1000, // 重连基础延迟 + maxReconnectDelay: 30000, // 最大重连延迟 + heartbeatMessage: 'ping', // 心跳消息 + heartbeatResponseType: 'pong', // 心跳响应类型 +}; +``` + +**配置设计原则:** + +1. **集中管理**:所有配置在一个地方,易于维护 +2. **合理默认值**:开箱即用的配置 +3. **易于调整**:生产环境可以快速调整参数 +4. **文档化**:每个配置都有清晰的注释 + +## 接口兼容性 + +### 原有接口保持不变 + +```typescript +// 原有接口 +function create(path: string): Promise { + const baseUrl = import.meta.env.ENV_WEBSOCKET_BASE ?? ''; + const ws = new EnhancedWebSocket(path, baseUrl) as any; + + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('WebSocket connection timeout')); + }, 10000); + + ws.addEventListener('open', () => { + clearTimeout(timeout); + resolve(ws); // 返回增强的WebSocket,但类型为WebSocket + }); + + ws.addEventListener('error', (e: any) => { + clearTimeout(timeout); + reject(e); + }); + }); +} +``` + +**兼容性保证:** + +1. **相同的函数签名**:`create(path: string): Promise` +2. **相同的返回类型**:返回Promise +3. **相同的使用方式**:业务代码无需任何修改 + +## 总结 + +### 技术选择总结 + +| 技术选择 | 原因 | 替代方案 | 为什么不选择替代方案 | +| ------------- | -------------------------------- | ------------ | ----------------------- | +| Class | 状态封装、方法绑定、生命周期管理 | 函数+闭包 | 复杂度高,类型支持差 | +| 包装器模式 | 控制创建时机、事件拦截 | 继承 | 无法在事件设置前拦截 | +| Private成员 | 封装、API稳定性、状态保护 | Public成员 | 容易被误用,状态不安全 | +| Getter/Setter | 透明代理、API兼容性 | 直接方法 | 不符合WebSocket API习惯 | +| 多定时器 | 职责分离、精确控制 | 单定时器 | 逻辑混乱,难以维护 | +| 状态标志 | 精确控制重连逻辑 | 仅依赖状态码 | WebSocket状态码限制多 | + +### 架构优势 + +1. **零侵入性**:业务代码完全无需修改 +2. **高可靠性**:多重保障确保连接稳定 +3. **高可维护性**:清晰的架构和完整的类型支持 +4. **高性能**:最小的性能开销 +5. **高扩展性**:易于添加新功能 + +### 最佳实践体现 + +1. **单一职责原则**:每个方法只负责一个功能 +2. **开闭原则**:对扩展开放,对修改封闭 +3. **依赖倒置原则**:依赖抽象(接口)而非具体实现 +4. **接口隔离原则**:用户只看到需要的接口 +5. **里氏替换原则**:增强版本完全可以替换原版本 + +这个实现展示了如何在保持向后兼容的同时,提供企业级的功能增强,是一个很好的渐进式增强的例子。 diff --git a/package.json b/package.json index 3223b8a..b1d08f4 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ "scripts": { "start": "vite", "build": "vue-tsc -b && vite build", - "copy-mocks": "node -e \"const fs=require('fs');const path=require('path');function copyDir(s,d){if(!fs.existsSync(d))fs.mkdirSync(d,{recursive:true});fs.readdirSync(s).forEach(f=>{const sp=path.join(s,f),dp=path.join(d,f);fs.statSync(sp).isDirectory()?copyDir(sp,dp):fs.copyFileSync(sp,dp)})}copyDir('mocks','dist/mocks')\"", "preview": "vite preview" }, "dependencies": { diff --git a/src/apis/map/constant.ts b/src/apis/map/constant.ts index 51940bf..0b143f3 100644 --- a/src/apis/map/constant.ts +++ b/src/apis/map/constant.ts @@ -1,77 +1,160 @@ import { KeydownType, type Options } from '@meta2d/core'; import { invert } from 'lodash-es'; +/** + * 地图点位类型枚举 + * 定义了场景编辑器中所有支持的点位类型 + * 数值1-9为小点位,10以上为大点位(有特殊图标) + */ //#region 点位 export enum MapPointType { + /** 普通点 - 基础导航点,机器人可通过 */ 普通点 = 1, + /** 等待点 - 机器人等待或暂停的位置 */ 等待点, + /** 避让点 - 机器人主动避让其他机器人的点位 */ 避让点, + /** 临时避让点 - 动态生成的临时避让位置,有特殊标记 */ 临时避让点, + /** 电梯点 - 机器人乘坐电梯的专用点位 */ 电梯点 = 11, + /** 自动门点 - 需要自动门控制的通行点位 */ 自动门点, + /** 充电点 - 机器人充电的专用位置,可绑定特定机器人 */ 充电点, + /** 停靠点 - 机器人停靠等待的位置,可绑定特定机器人 */ 停靠点, + /** 动作点 - 机器人执行特定动作的位置(如取货、放货) */ 动作点, + /** 禁行点 - 禁止机器人通过的点位 */ 禁行点, } + +/** + * 点位类型映射数组,用于UI显示和选择 + * 过滤掉非数字类型的枚举项 + */ export const MAP_POINT_TYPES = Object.freeze( <[string, MapPointType][]>Object.entries(MapPointType).filter(([, v]) => typeof v === 'number'), ); //#endregion +/** + * 地图路线类型枚举 + * 定义了连接点位之间的路径类型 + */ //#region 线路 export enum MapRouteType { + /** 直线 - 两点间直线连接 */ 直线 = 'line', + /** 二阶贝塞尔曲线 - 带一个控制点的曲线 */ 二阶贝塞尔曲线 = 'bezier2', + /** 三阶贝塞尔曲线 - 带两个控制点的曲线,更灵活 */ 三阶贝塞尔曲线 = 'bezier3', } + +/** + * 路线类型反向映射,用于从字符串值获取枚举键 + */ export const MAP_ROUTE_TYPE = invert(MapRouteType); + +/** + * 路线类型映射数组,用于UI显示和选择 + */ export const MAP_ROUTE_TYPES = Object.freeze(<[string, MapRouteType][]>Object.entries(MapRouteType)); +/** + * 路线通行类型枚举 + * 定义了路线的通行权限和限制 + */ export enum MapRoutePassType { + /** 无限制 - 所有机器人都可以通行 */ 无, + /** 仅空载可通行 - 只有空载的机器人可以通过 */ 仅空载可通行, + /** 仅载货可通行 - 只有载货的机器人可以通过 */ 仅载货可通行, + /** 禁行 - 禁止所有机器人通行,显示为虚线 */ 禁行 = 10, } + +/** + * 路线通行类型映射数组,用于UI显示和选择 + */ export const MAP_ROUTE_PASS_TYPES = Object.freeze( <[string, MapRoutePassType][]>Object.entries(MapRoutePassType).filter(([, v]) => typeof v === 'number'), ); //#endregion +/** + * 地图区域类型枚举 + * 定义了场景中不同功能的区域类型 + */ //#region 区域 export enum MapAreaType { + /** 库区 - 仓储作业区域,包含动作点 */ 库区 = 1, + /** 互斥区 - 同时只能有一个机器人进入的区域 */ 互斥区 = 11, + /** 非互斥区 - 可以同时有多个机器人进入的区域 */ 非互斥区, } + +/** + * 区域类型映射数组,用于UI显示和选择 + */ export const MAP_AREA_TYPES = Object.freeze( <[string, MapAreaType][]>Object.entries(MapAreaType).filter(([, v]) => typeof v === 'number'), ); //#endregion +/** + * 场景编辑器核心配置 + * 基于Meta2D引擎的编辑器配置参数 + */ export const EDITOR_CONFIG: Options = { + /** 键盘事件类型 - 禁用所有键盘快捷键 */ keydown: KeydownType.None, + /** 严格作用域 - 限制编辑操作范围 */ strictScope: true, + /** 移动连接线 - 禁用拖动时自动移动连接的线条 */ moveConnectedLine: false, + /** 禁用输入 - 禁用文本输入功能 */ disableInput: true, + /** 禁用旋转 - 禁用图形旋转功能 */ disableRotate: true, + /** 禁用尺寸调整 - 禁用图形大小调整 */ disableSize: true, + /** 禁用锚点 - 禁用连接锚点显示 */ disableAnchor: true, + /** 禁用空线条 - 不允许创建没有连接点的线条 */ disableEmptyLine: true, + /** 禁用重复线条 - 不允许在同一对点之间创建多条线 */ disableRepeatLine: true, + /** 最小缩放比例 - 画布最小缩放到24% */ minScale: 0.24, + /** 最大缩放比例 - 画布最大缩放到401% */ maxScale: 4.01, - scaleOff: 0.01, + /** 缩放步长 - 每次滚轮滚动的缩放幅度(5%) */ + scaleOff: 0.05, + /** 默认锚点 - 不设置默认锚点 */ defaultAnchors: [], + /** 全局透明度 - 普通状态下图形透明度(0为不透明) */ globalAlpha: 0, + /** 激活状态全局透明度 - 选中状态下图形透明度 */ activeGlobalAlpha: 0, + /** 默认字体大小 - 14像素 */ fontSize: 14, + /** 行高倍数 - 1.5倍行高 */ lineHeight: 1.5, + /** 字体族 - 使用系统默认字体 */ fontFamily: 'system-ui', + /** 文本旋转 - 禁用文本跟随图形旋转 */ textRotate: false, + /** 文本水平对齐 - 居中对齐 */ textAlign: 'center', + /** 文本垂直基线 - 顶部对齐 */ textBaseline: 'top', }; diff --git a/src/pages/movement-supervision.vue b/src/pages/movement-supervision.vue index 654e72d..1555d36 100644 --- a/src/pages/movement-supervision.vue +++ b/src/pages/movement-supervision.vue @@ -86,7 +86,7 @@ const selectRobot = (id: string) => { - {{ title }} + {{ title }}--场景仿真 diff --git a/src/pages/scene-editor.vue b/src/pages/scene-editor.vue index edf01bc..be8d67d 100644 --- a/src/pages/scene-editor.vue +++ b/src/pages/scene-editor.vue @@ -105,7 +105,7 @@ const selectRobot = (id: string) => { - {{ title }} + {{ title }} --场景编辑 diff --git a/src/services/editor.service.ts b/src/services/editor.service.ts index 03e20fa..7d7ca3f 100644 --- a/src/services/editor.service.ts +++ b/src/services/editor.service.ts @@ -27,8 +27,25 @@ import { clone, get, isEmpty, isNil, isString, nth, omitBy, pick, remove, some } import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from 'rxjs'; import { reactive, watch } from 'vue'; +/** + * 场景编辑器服务类 + * 继承自Meta2D,提供完整的场景编辑功能 + * + * 主要功能: + * - 场景文件的加载、保存和管理 + * - 点位、路线、区域的创建和编辑 + * - 机器人组的管理和实时状态更新 + * - 鼠标事件的处理和响应式数据流 + * - 自定义绘制和渲染逻辑 + */ export class EditorService extends Meta2d { - //#region 场景文件 + //#region 场景文件管理 + /** + * 加载场景文件到编辑器 + * @param map 场景文件的JSON字符串,为空则创建新场景 + * @param editable 是否可编辑状态,控制编辑器锁定状态 + * @param detail 群组场景详情,包含机器人组和机器人信息 + */ public async load(map?: string, editable = false, detail?: Partial): Promise { const scene: StandardScene = map ? JSON.parse(map) : {}; if (!isEmpty(detail?.group)) { @@ -45,6 +62,10 @@ export class EditorService extends Meta2d { this.store.historyIndex = undefined; this.store.histories = []; } + /** + * 保存当前场景为JSON字符串 + * @returns 包含完整场景数据的JSON字符串 + */ public save(): string { const scene: StandardScene = { robotGroups: this.robotGroups.value, @@ -57,11 +78,20 @@ export class EditorService extends Meta2d { return JSON.stringify(scene); } + /** + * 加载机器人数据到编辑器 + * @param groups 机器人组列表 + * @param robots 机器人信息列表 + */ #loadRobots(groups?: RobotGroup[], robots?: RobotInfo[]): void { this.#robotMap.clear(); robots?.forEach((v) => this.#robotMap.set(v.id, v)); this.#robotGroups$$.next(groups ?? []); } + /** + * 从场景数据加载点位到画布 + * @param points 标准场景点位数据数组 + */ async #loadScenePoints(points?: StandardScenePoint[]): Promise { if (!points?.length) return; await Promise.all( @@ -75,6 +105,10 @@ export class EditorService extends Meta2d { }), ); } + /** + * 从场景数据加载路线到画布 + * @param routes 标准场景路线数据数组 + */ #loadSceneRoutes(routes?: StandardSceneRoute[]): void { if (!routes?.length) return; routes.map((v) => { @@ -206,6 +240,10 @@ export class EditorService extends Meta2d { } //#endregion + /** + * 设置编辑器状态 + * @param editable 是否可编辑,true为可编辑状态,false为只读状态 + */ public setState(editable?: boolean): void { this.lock(editable ? LockState.None : LockState.DisableEdit); } @@ -214,7 +252,10 @@ export class EditorService extends Meta2d { return super.data(); } + /** 鼠标事件流主体,用于内部事件分发 */ readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>(); + + /** 鼠标点击事件的响应式流,防抖处理后的点击坐标 */ public readonly mouseClick = useObservable( this.#mouse$$.pipe( filter(({ type }) => type === 'click'), @@ -222,6 +263,8 @@ export class EditorService extends Meta2d { map(({ value }) => value), ), ); + + /** 鼠标拖拽事件的响应式流,返回起始点和结束点坐标,用于创建区域 */ public readonly mouseBrush = useObservable<[Point, Point]>( this.#mouse$$.pipe( filter(({ type }) => type === 'mousedown'), @@ -234,8 +277,11 @@ export class EditorService extends Meta2d { ), ); - //#region 机器人组 + //#region 机器人组管理 + /** 机器人信息映射表,响应式存储所有机器人数据 */ readonly #robotMap = reactive>(new Map()); + + /** 获取所有机器人信息数组 */ public get robots(): RobotInfo[] { return Array.from(this.#robotMap.values()); } @@ -319,13 +365,18 @@ export class EditorService extends Meta2d { } //#endregion + /** 画布变化事件流,用于触发响应式数据更新 */ readonly #change$$ = new Subject(); + + /** 当前选中的图形对象,响应式更新 */ public readonly current = useObservable( this.#change$$.pipe( debounceTime(100), map(() => clone(this.store.active?.[0])), ), ); + + /** 当前选中的图形ID列表,响应式更新 */ public readonly selected = useObservable( this.#change$$.pipe( filter((v) => !v), @@ -334,6 +385,8 @@ export class EditorService extends Meta2d { ), { initialValue: new Array() }, ); + + /** 画布上所有图形对象列表,响应式更新 */ public readonly pens = useObservable( this.#change$$.pipe( filter((v) => v), @@ -432,6 +485,7 @@ export class EditorService extends Meta2d { //#endregion //#region 点位 + /** 画布上所有点位对象列表,响应式更新 */ public readonly points = useObservable( this.#change$$.pipe( filter((v) => v), @@ -447,6 +501,12 @@ export class EditorService extends Meta2d { return { x: x + width / 2, y: y + height / 2, width, height }; } + /** + * 在指定位置添加点位 + * @param p 点位坐标 + * @param type 点位类型,默认为普通点 + * @param id 点位ID,未指定则自动生成 + */ public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { id ||= s8(); const pen: MapPen = { @@ -503,6 +563,7 @@ export class EditorService extends Meta2d { //#endregion //#region 线路 + /** 画布上所有路线对象列表,响应式更新,包含动态生成的标签 */ public readonly routes = useObservable( this.#change$$.pipe( filter((v) => v), @@ -525,6 +586,12 @@ export class EditorService extends Meta2d { return `${p1.label}${(d ?? direction) > 0 ? '→' : '←'}${p2.label}`; } + /** + * 在两个点位之间添加路线 + * @param p 两个点位的数组 + * @param type 路线类型,默认为直线 + * @param id 路线ID,未指定则自动生成 + */ public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void { const [p1, p2] = p; if (!p1?.anchors?.length || !p2?.anchors?.length) return; @@ -553,6 +620,7 @@ export class EditorService extends Meta2d { //#endregion //#region 区域 + /** 画布上所有区域对象列表,响应式更新 */ public readonly areas = useObservable( this.#change$$.pipe( filter((v) => v), @@ -571,6 +639,13 @@ export class EditorService extends Meta2d { }); } + /** + * 在指定区域添加功能区域 + * @param p1 区域起始坐标 + * @param p2 区域结束坐标 + * @param type 区域类型,默认为库区 + * @param id 区域ID,未指定则自动生成 + */ public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { const scale = this.data().scale ?? 1; const w = Math.abs(p1.x - p2.x); @@ -621,13 +696,21 @@ export class EditorService extends Meta2d { } //#endregion + /** + * 构造函数 - 初始化场景编辑器 + * @param container 编辑器容器DOM元素 + */ constructor(container: HTMLDivElement) { super(container, EDITOR_CONFIG); + // 禁用第6个子元素的拖放功能 (container.children.item(5)).ondrop = null; + // 监听所有画布事件 this.on('*', (e, v) => this.#listen(e, v)); + // 注册自定义绘制函数和锚点 this.#register(); + // 监听主题变化并重新加载样式 watch( () => sTheme.theme, (v) => this.#load(v), @@ -711,7 +794,12 @@ export class EditorService extends Meta2d { } } -//#region 绘制函数 +//#region 自定义绘制函数 +/** + * 绘制点位的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 点位图形对象 + */ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, iconSize: r = 0, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; @@ -777,6 +865,10 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); ctx.restore(); } +/** + * 设置点位的连接锚点 + * @param pen 点位图形对象 + */ function anchorPoint(pen: MapPen): void { pen.anchors = [ { penId: pen.id, id: '0', x: 0.5, y: 0.5 }, @@ -787,6 +879,11 @@ function anchorPoint(pen: MapPen): void { ]; } +/** + * 绘制路线的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 路线图形对象 + */ function drawLine(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, lineWidth: s = 1 } = pen.calculative ?? {}; @@ -874,6 +971,11 @@ function lineBezier3(_: Meta2dStore, pen: MapPen): void { pen.calculative.worldAnchors[1].prev = { x: x2 + dx2 * s, y: y2 + dy2 * s }; } +/** + * 绘制区域的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 区域图形对象 + */ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; @@ -895,6 +997,11 @@ function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { ctx.restore(); } +/** + * 绘制机器人的自定义函数 + * @param ctx Canvas 2D绘制上下文 + * @param pen 机器人图形对象 + */ function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { const theme = sTheme.editor; const { lineWidth: s = 1 } = pen.calculative ?? {}; diff --git a/src/services/http.ts b/src/services/http.ts index 845ca40..1c0ff06 100644 --- a/src/services/http.ts +++ b/src/services/http.ts @@ -11,15 +11,29 @@ export default http; // 添加请求拦截器 http.interceptors.request.use( - (config) => { + async (config) => { try { - const token = - JSON.parse(localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}')?.value - .TOKEN__.value || ''; + let token = ''; + let tenantId = ''; + + // 开发环境处理逻辑 + if (import.meta.env.DEV) { + try { + token = import.meta.env.ENV_DEV_TOKEN; + tenantId = import.meta.env.ENV_DEV_TENANT_ID; + } catch (error) { + console.error('获取开发环境token失败:', error); + } + } else { + // 生产环境直接从localStorage获取 + const localStorageData = JSON.parse( + localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}', + ); + token = localStorageData?.value?.TOKEN__?.value || ''; + tenantId = localStorageData?.value?.TENANT_ID?.value || ''; + } + config.headers['x-access-token'] = token; - const tenantId = - JSON.parse(localStorage.getItem('VWED_AMR调度系统__PRODUCTION__3.7.1__COMMON__LOCAL__KEY__') || '{}')?.value - .TENANT_ID.value || ''; config.headers['x-tenant-id'] = tenantId; console.log(config); } catch (error) { diff --git a/src/services/ws.ts b/src/services/ws.ts index a35036b..bb0c2d8 100644 --- a/src/services/ws.ts +++ b/src/services/ws.ts @@ -1,8 +1,328 @@ +// WebSocket全局配置 +const WS_CONFIG = { + heartbeatInterval: 30000, // 30秒心跳间隔 + heartbeatTimeout: 5000, // 心跳响应超时时间(5秒) + maxReconnectAttempts: 5, // 最大重连次数 + reconnectBaseDelay: 1000, // 重连基础延迟1秒 + maxReconnectDelay: 30000, // 最大重连延迟30秒 + heartbeatMessage: 'ping', // 心跳消息 + heartbeatResponseType: 'pong', // 心跳响应类型 +}; + +// 增强的WebSocket包装器 +class EnhancedWebSocket { + private ws: WebSocket; + private path: string; + private baseUrl: string; + private heartbeatTimer?: NodeJS.Timeout; + private heartbeatTimeoutTimer?: NodeJS.Timeout; + private reconnectTimer?: NodeJS.Timeout; + private reconnectAttempts: number = 0; + private isManualClose: boolean = false; + private isHeartbeatTimeout: boolean = false; + private userOnMessage: ((event: MessageEvent) => void) | null = null; + private userOnClose: ((event: CloseEvent) => void) | null = null; + private userOnError: ((event: Event) => void) | null = null; + private userOnOpen: ((event: Event) => void) | null = null; + + constructor(path: string, baseUrl: string) { + this.path = path; + this.baseUrl = baseUrl; + this.ws = new WebSocket(baseUrl + path); + this.setupHandlers(); + } + + // 设置事件处理器 + private setupHandlers(): void { + this.ws.onopen = (event) => { + console.log(`WebSocket连接已建立: ${this.path}`); + this.reconnectAttempts = 0; + this.clearReconnectTimer(); + this.startHeartbeat(); + + if (this.userOnOpen) { + this.userOnOpen(event); + } + }; + + this.ws.onmessage = (event) => { + const messageData = event.data; + + // 检查是否为心跳响应(支持字符串和JSON格式) + let isHeartbeatResponse = false; + + // 1. 检查是否为简单字符串心跳响应 + if (typeof messageData === 'string' && messageData === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + + // 2. 检查是否为JSON格式心跳响应 + if (!isHeartbeatResponse && typeof messageData === 'string') { + try { + const data = JSON.parse(messageData); + if (data.type === WS_CONFIG.heartbeatResponseType) { + isHeartbeatResponse = true; + } + } catch (e) { + // JSON解析失败,不是JSON格式的心跳响应 + } + } + + if (isHeartbeatResponse) { + // 收到心跳响应,清除超时定时器 + this.clearHeartbeatTimeout(); + return; + } + + // 传递给业务代码 + if (this.userOnMessage) { + this.userOnMessage(event); + } + }; + + this.ws.onclose = (event) => { + console.log(`WebSocket连接关闭: ${this.path}`, event.code, event.reason); + this.stopHeartbeat(); + + // 先调用业务代码的关闭处理 + if (this.userOnClose) { + this.userOnClose(event); + } + + // 如果不是手动关闭,或者是心跳超时导致的关闭,则重连 + if (!this.isManualClose || this.isHeartbeatTimeout) { + this.scheduleReconnect(); + } + + // 重置心跳超时标志 + this.isHeartbeatTimeout = false; + }; + + this.ws.onerror = (event) => { + console.error(`WebSocket连接错误: ${this.path}`, event); + this.stopHeartbeat(); + + if (this.userOnError) { + this.userOnError(event); + } + }; + } + + // 开始心跳检测 + private startHeartbeat(): void { + this.stopHeartbeat(); + console.log(`开始心跳检测: ${this.path}, 间隔: ${WS_CONFIG.heartbeatInterval}ms`); + this.heartbeatTimer = setInterval(() => { + if (this.ws.readyState === WebSocket.OPEN) { + this.ws.send(WS_CONFIG.heartbeatMessage); + + // 只有在没有进行超时检测时才设置新的超时检测 + if (!this.heartbeatTimeoutTimer) { + this.startHeartbeatTimeout(); + } + } + }, WS_CONFIG.heartbeatInterval); + } + + // 停止心跳检测 + private stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = undefined; + } + this.clearHeartbeatTimeout(); + } + + // 开始心跳响应超时检测 + private startHeartbeatTimeout(): void { + // 不再自动清除,只在收到响应时清除 + this.heartbeatTimeoutTimer = setTimeout(() => { + console.log(`心跳响应超时: ${this.path}, ${WS_CONFIG.heartbeatTimeout}ms内未收到响应,主动断开连接`); + // 设置心跳超时标志,触发重连 + this.isHeartbeatTimeout = true; + this.ws.close(1000, 'Heartbeat timeout'); // 使用正常关闭状态码,通过标志来判断是否重连 + }, WS_CONFIG.heartbeatTimeout); + } + + // 清除心跳响应超时检测 + private clearHeartbeatTimeout(): void { + if (this.heartbeatTimeoutTimer) { + clearTimeout(this.heartbeatTimeoutTimer); + this.heartbeatTimeoutTimer = undefined; + } + } + + // 安排重连 + private scheduleReconnect(): void { + if (this.isManualClose || this.reconnectAttempts >= WS_CONFIG.maxReconnectAttempts) { + console.log(`停止重连: ${this.path}, 手动关闭: ${this.isManualClose}, 重连次数: ${this.reconnectAttempts}`); + return; + } + + this.reconnectAttempts++; + + // 指数退避重连策略 + const delay = Math.min( + WS_CONFIG.reconnectBaseDelay * Math.pow(2, this.reconnectAttempts - 1), + WS_CONFIG.maxReconnectDelay, + ); + + console.log( + `WebSocket将在${delay}ms后重连: ${this.path} (${this.reconnectAttempts}/${WS_CONFIG.maxReconnectAttempts})`, + ); + + this.reconnectTimer = setTimeout(() => { + this.reconnect(); + }, delay); + } + + // 重连逻辑 + private reconnect(): void { + if (this.isManualClose) return; + + console.log(`WebSocket重连尝试: ${this.path} (${this.reconnectAttempts}/${WS_CONFIG.maxReconnectAttempts})`); + + // 创建新的WebSocket连接 + this.ws = new WebSocket(this.baseUrl + this.path); + this.setupHandlers(); + } + + // 清理重连定时器 + private clearReconnectTimer(): void { + if (this.reconnectTimer) { + clearTimeout(this.reconnectTimer); + this.reconnectTimer = undefined; + } + } + + // 公开的WebSocket属性和方法 + get readyState(): number { + return this.ws.readyState; + } + + get url(): string { + return this.ws.url; + } + + get protocol(): string { + return this.ws.protocol; + } + + get extensions(): string { + return this.ws.extensions; + } + + get bufferedAmount(): number { + return this.ws.bufferedAmount; + } + + get binaryType(): BinaryType { + return this.ws.binaryType; + } + + set binaryType(value: BinaryType) { + this.ws.binaryType = value; + } + + // 事件处理器属性 + get onopen(): ((event: Event) => void) | null { + return this.userOnOpen; + } + + set onopen(handler: ((event: Event) => void) | null) { + this.userOnOpen = handler; + } + + get onmessage(): ((event: MessageEvent) => void) | null { + return this.userOnMessage; + } + + set onmessage(handler: ((event: MessageEvent) => void) | null) { + this.userOnMessage = handler; + } + + get onclose(): ((event: CloseEvent) => void) | null { + return this.userOnClose; + } + + set onclose(handler: ((event: CloseEvent) => void) | null) { + this.userOnClose = handler; + } + + get onerror(): ((event: Event) => void) | null { + return this.userOnError; + } + + set onerror(handler: ((event: Event) => void) | null) { + this.userOnError = handler; + } + + // WebSocket方法 + send(data: string | ArrayBufferLike | Blob | ArrayBufferView): void { + this.ws.send(data); + } + + close(code?: number, reason?: string): void { + console.log(`手动关闭WebSocket: ${this.path}`); + this.isManualClose = true; + this.isHeartbeatTimeout = false; // 手动关闭时重置心跳超时标志 + this.stopHeartbeat(); + this.clearReconnectTimer(); + this.ws.close(code, reason); + } + + addEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | AddEventListenerOptions, + ): void { + this.ws.addEventListener(type, listener, options); + } + + removeEventListener( + type: K, + listener: (this: WebSocket, ev: WebSocketEventMap[K]) => any, + options?: boolean | EventListenerOptions, + ): void { + this.ws.removeEventListener(type, listener, options); + } + + dispatchEvent(event: Event): boolean { + return this.ws.dispatchEvent(event); + } + + // 常量 + static readonly CONNECTING = WebSocket.CONNECTING; + static readonly OPEN = WebSocket.OPEN; + static readonly CLOSING = WebSocket.CLOSING; + static readonly CLOSED = WebSocket.CLOSED; + + readonly CONNECTING = WebSocket.CONNECTING; + readonly OPEN = WebSocket.OPEN; + readonly CLOSING = WebSocket.CLOSING; + readonly CLOSED = WebSocket.CLOSED; +} + function create(path: string): Promise { - const ws = new WebSocket((import.meta.env.ENV_WEBSOCKET_BASE ?? '') + path); + const baseUrl = import.meta.env.ENV_WEBSOCKET_BASE ?? ''; + const ws = new EnhancedWebSocket(path, baseUrl) as any; + return new Promise((resolve, reject) => { - ws.onopen = () => resolve(ws); - ws.onerror = (e) => reject(e); + const timeout = setTimeout(() => { + ws.close(); + reject(new Error('WebSocket connection timeout')); + }, 10000); // 10秒连接超时 + + ws.addEventListener('open', () => { + clearTimeout(timeout); + resolve(ws); + }); + + ws.addEventListener('error', (e: any) => { + clearTimeout(timeout); + reject(e); + }); }); } + export default { create }; diff --git a/vite.config.ts b/vite.config.ts index 86fb44e..9bcb965 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -45,12 +45,12 @@ export default ({ mode }: Record) => proxy: { '/mocks/': { target: 'http://localhost:8888/web-amr' }, '/api/': { - target: 'http://82.156.39.91:18080/jeecg-boot', + target: 'http://192.168.189.206:8080/jeecg-boot', rewrite: (path) => path.replace(/^\/api/, ''), changeOrigin: true, }, '/ws/': { - target: 'ws://82.156.39.91:18080/jeecg-boot', + target: 'ws://192.168.189.206:8080/jeecg-boot', rewrite: (path) => path.replace(/^\/ws/, ''), changeOrigin: true, ws: true, diff --git a/场景编辑器组件详细分析.md b/场景编辑器组件详细分析.md new file mode 100644 index 0000000..8bfc833 --- /dev/null +++ b/场景编辑器组件详细分析.md @@ -0,0 +1,2013 @@ +# 场景编辑器组件详细分析 + +## 1. 组件概述 + +`scene-editor.vue` 是一个基于 Vue 3 的复杂场景编辑器组件,主要用于管理和编辑工业机器人的场景配置。该组件提供了完整的场景编辑功能,包括机器人管理、路径规划、区域设置等。 + +## 2. 架构图示分析 + +### 2.1 组件整体架构图 + +```mermaid +graph TB + subgraph "Scene Editor Component" + A[scene-editor.vue] --> B[EditorService] + A --> C[RobotGroups] + A --> D[PenGroups] + A --> E[EditorToolbar] + A --> F[Detail Cards] + + B --> B1[Meta2d Engine] + B --> B2[Canvas Rendering] + B --> B3[Event System] + + C --> C1[Robot Management] + C --> C2[Group Operations] + + D --> D1[Points Management] + D --> D2[Routes Management] + D --> D3[Areas Management] + + E --> E1[Drawing Tools] + E --> E2[Operations] + + F --> F1[Robot Detail] + F --> F2[Point Detail] + F --> F3[Route Detail] + F --> F4[Area Detail] + end + + subgraph "External Dependencies" + G[Scene API] + H[Robot API] + I[Map API] + J[File System] + end + + A --> G + A --> H + B --> I + A --> J +``` + +### 2.2 数据流架构图 + +```mermaid +sequenceDiagram + participant U as User + participant SE as SceneEditor + participant ES as EditorService + participant API as SceneAPI + participant Canvas as Meta2d Canvas + + U->>SE: Load Scene + SE->>API: getSceneById(id) + API-->>SE: Scene Data + SE->>ES: load(sceneData) + ES->>Canvas: Render Elements + + U->>SE: Edit Element + SE->>ES: updatePen/addArea/addPoint + ES->>Canvas: Update Render + ES->>SE: Emit Change Event + SE->>SE: Update UI State + + U->>SE: Save Scene + SE->>ES: save() + ES-->>SE: JSON String + SE->>API: saveSceneById(id, json) + API-->>SE: Success Response +``` + +### 2.3 EditorService 内部架构图 + +```mermaid +graph LR + subgraph "EditorService Core" + A[Meta2d Base] --> B[Event System] + A --> C[Canvas Layer] + A --> D[State Management] + + B --> B1[Mouse Events] + B --> B2[Change Events] + B --> B3[RxJS Streams] + + C --> C1[Point Rendering] + C --> C2[Route Rendering] + C --> C3[Area Rendering] + C --> C4[Robot Rendering] + + D --> D1[Current Selection] + D --> D2[Robot Groups] + D --> D3[Elements Cache] + end + + subgraph "Rendering Pipeline" + E[drawPoint] --> F[Canvas Context] + G[drawLine] --> F + H[drawArea] --> F + I[drawRobot] --> F + end + + C1 --> E + C2 --> G + C3 --> H + C4 --> I +``` + +## 3. 核心功能分析 + +### 3.1 场景数据管理 + +- **场景读取**: 通过 `getSceneById` API 获取场景数据 +- **场景推送**: 通过 `pushSceneById` API 将场景数据推送到数据库 +- **场景保存**: 通过编辑器服务保存场景配置 +- **文件导入/导出**: 支持 `.scene` 格式文件的导入导出 + +### 3.2 编辑器状态控制 + +- **编辑模式切换**: 通过 `editable` 状态控制编辑器的启用/禁用 +- **权限管理**: 根据编辑状态显示不同的操作按钮和功能 +- **实时状态同步**: 编辑状态变化时自动更新编辑器服务状态 + +### 3.3 三大管理区域 + +- **机器人管理**: 显示和管理场景中的机器人组和单个机器人 +- **库区管理**: 管理各种类型的库区(仅显示库区类型的区域) +- **高级组管理**: 管理复杂的路径、点位、区域等元素 + +### 3.4 详情卡片系统 + +- **动态卡片显示**: 根据选中元素类型显示对应的详情卡片 +- **编辑/查看模式**: 根据编辑状态显示编辑卡片或查看卡片 +- **悬浮定位**: 卡片固定在右侧悬浮显示 + +## 4. 大场景渲染性能优化分析 + +### 4.1 性能瓶颈识别 + +#### 4.1.1 主要性能问题 + +```mermaid +graph TD + A[大场景性能问题] --> B[元素数量过多] + A --> C[频繁重绘] + A --> D[内存泄漏] + A --> E[事件处理] + + B --> B1[点位: 1000+ 个] + B --> B2[路线: 5000+ 条] + B --> B3[区域: 100+ 个] + B --> B4[机器人: 50+ 个] + + C --> C1[每次状态变更全量重绘] + C --> C2[鼠标移动频繁触发] + C --> C3[RxJS 防抖不足] + + D --> D1[大量 DOM 监听器] + D --> D2[Canvas 上下文未释放] + D --> D3[图片资源未缓存] + + E --> E1[hit-test 计算复杂] + E --> E2[事件冒泡处理] +``` + +#### 4.1.2 当前代码中的性能问题点 + +```typescript +// 问题1: 频繁的全量数据更新 +public readonly pens = useObservable( + this.#change$$.pipe( + filter((v) => v), + debounceTime(100), // 防抖时间过短 + map(() => this.data().pens), // 每次返回全量数据 + ), +); + +// 问题2: 复杂的过滤操作 +const robots = computed(() => + editor.value.robots.filter(({ label }) => + label.includes(keyword.value) // 每次重新过滤全部数据 + ) +); + +// 问题3: 同步的大量元素创建 +async #loadScenePoints(points?: StandardScenePoint[]): Promise { + if (!points?.length) return; + await Promise.all( // 并发创建所有点位,可能导致界面卡顿 + points.map(async (v) => { + // ... 创建逻辑 + }), + ); +} +``` + +### 4.2 性能优化策略 + +#### 4.2.1 虚拟化渲染优化 + +```typescript +// 优化建议1: 实现视口裁剪 +class ViewportCulling { + private viewport: Rect; + private visibleElements: Map = new Map(); + + updateViewport(viewport: Rect): void { + this.viewport = viewport; + this.updateVisibleElements(); + } + + private updateVisibleElements(): void { + const elements = this.getAllElements(); + this.visibleElements.clear(); + + elements.forEach((element) => { + if (this.isInViewport(element)) { + this.visibleElements.set(element.id, element); + } + }); + } + + private isInViewport(element: MapPen): boolean { + const elementRect = this.getElementRect(element); + return this.rectIntersects(this.viewport, elementRect); + } +} + +// 优化建议2: 层级渲染 +class LayeredRenderer { + private staticLayer: CanvasRenderingContext2D; // 静态elementos(点位、区域) + private dynamicLayer: CanvasRenderingContext2D; // 动态elementos(机器人、路径) + private uiLayer: CanvasRenderingContext2D; // UI层(选中状态、工具提示) + + render(): void { + this.renderStaticLayer(); // 仅在元素变更时重绘 + this.renderDynamicLayer(); // 频繁重绘 + this.renderUILayer(); // 交互时重绘 + } +} +``` + +#### 4.2.2 数据结构优化 + +```typescript +// 优化建议3: 使用空间索引 +class SpatialIndex { + private quadTree: QuadTree; + + insert(element: MapPen): void { + const bounds = this.getElementBounds(element); + this.quadTree.insert(element, bounds); + } + + query(viewport: Rect): MapPen[] { + return this.quadTree.query(viewport); + } +} + +// 优化建议4: 缓存计算结果 +class RenderCache { + private pathCache: Map = new Map(); + private imageCache: Map = new Map(); + + getPath(element: MapPen): Path2D { + const key = this.getPathKey(element); + if (!this.pathCache.has(key)) { + this.pathCache.set(key, this.createPath(element)); + } + return this.pathCache.get(key)!; + } +} +``` + +#### 4.2.3 事件处理优化 + +```typescript +// 优化建议5: 事件委托和节流 +class EventOptimizer { + private mouseThrottle = throttle(this.handleMouseMove.bind(this), 16); // 60fps + + setupEventListeners(): void { + // 使用事件委托,减少监听器数量 + this.canvas.addEventListener('mousemove', this.mouseThrottle); + this.canvas.addEventListener('click', this.handleClick); + } + + private handleMouseMove(event: MouseEvent): void { + // 只处理必要的鼠标移动事件 + const elements = this.spatialIndex.query(this.getMouseViewport(event)); + this.updateHoverState(elements); + } +} +``` + +#### 4.2.4 内存管理优化 + +```typescript +// 优化建议6: 对象池模式 +class ObjectPool { + private pool: T[] = []; + + acquire(): T { + return this.pool.pop() || this.create(); + } + + release(obj: T): void { + this.reset(obj); + this.pool.push(obj); + } +} + +// 优化建议7: 及时清理资源 +class ResourceManager { + private observers: Set<() => void> = new Set(); + + cleanup(): void { + this.observers.forEach((cleanup) => cleanup()); + this.observers.clear(); + + // 清理Canvas上下文 + this.clearCanvasContexts(); + + // 清理图片缓存 + this.clearImageCache(); + } +} +``` + +### 4.3 具体优化实施建议 + +#### 4.3.1 分批加载策略 + +```typescript +// 建议实现: 分批加载大量元素 +async loadSceneInBatches(scene: StandardScene): Promise { + const BATCH_SIZE = 100; + + // 分批加载点位 + if (scene.points?.length) { + for (let i = 0; i < scene.points.length; i += BATCH_SIZE) { + const batch = scene.points.slice(i, i + BATCH_SIZE); + await this.loadPointsBatch(batch); + await this.nextTick(); // 让出主线程 + } + } + + // 分批加载路线 + if (scene.routes?.length) { + for (let i = 0; i < scene.routes.length; i += BATCH_SIZE) { + const batch = scene.routes.slice(i, i + BATCH_SIZE); + await this.loadRoutesBatch(batch); + await this.nextTick(); + } + } +} + +private nextTick(): Promise { + return new Promise(resolve => setTimeout(resolve, 0)); +} +``` + +#### 4.3.2 LOD(Level of Detail)优化 + +```typescript +// 建议实现: 根据缩放级别调整渲染详度 +class LODRenderer { + private renderLevel = 0; + + updateRenderLevel(scale: number): void { + if (scale > 2) + this.renderLevel = 3; // 高详度 + else if (scale > 1) + this.renderLevel = 2; // 中详度 + else if (scale > 0.5) + this.renderLevel = 1; // 低详度 + else this.renderLevel = 0; // 最低详度 + } + + renderPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { + switch (this.renderLevel) { + case 0: + this.renderPointSimple(ctx, pen); + break; + case 1: + this.renderPointNormal(ctx, pen); + break; + case 2: + this.renderPointDetailed(ctx, pen); + break; + case 3: + this.renderPointHighDetail(ctx, pen); + break; + } + } +} +``` + +## 5. 异步区域绘制深层原因分析 + +### 5.1 为什么使用 async/await + +#### 5.1.1 代码分析 + +```typescript +// 关键代码段分析 +public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { + // ... 前置逻辑 + const area = await this.addPen(pen, true, true, true); + // ↑ 这里是关键 - addPen 是异步的 + this.bottom(area); +} + +public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { + // ... 创建pen对象 + await this.addPen(pen, false, true, true); + // ↑ 同样是异步调用 +} +``` + +#### 5.1.2 深层原因分析图 + +```mermaid +graph TD + A[addArea/addPoint 调用] --> B[创建 MapPen 对象] + B --> C[调用 addPen 方法] + C --> D{addPen 为什么异步?} + + D --> E[Canvas 渲染队列] + D --> F[DOM 更新时机] + D --> G[图片资源加载] + D --> H[动画系统集成] + + E --> E1[Canvas需要等待渲染完成] + E --> E2[避免渲染冲突] + E --> E3[批量更新优化] + + F --> F1[等待浏览器重绘] + F --> F2[确保DOM状态同步] + + G --> G1[点位图标加载] + G --> G2[机器人图片加载] + G --> G3[主题相关资源] + + H --> H1[过渡动画] + H --> H2[缩放动画] + H --> H3[状态切换动画] +``` + +### 5.2 Meta2d 底层机制分析 + +#### 5.2.1 addPen 异步的根本原因 + +```typescript +// Meta2d 内部可能的实现机制 (推测) +class Meta2d { + async addPen(pen: Pen, history?: boolean, render?: boolean, doEvent?: boolean): Promise { + // 1. 资源预加载 - 确保图片等资源准备就绪 + if (pen.image) { + await this.loadImage(pen.image); + } + + // 2. 渲染管道同步 - 等待当前渲染任务完成 + await this.renderQueue.nextTick(); + + // 3. 添加到画布数据结构 + this.store.data.pens.push(pen); + + // 4. 计算布局和碰撞检测 + await this.calculateLayout(pen); + + // 5. 触发重绘 + if (render) { + await this.render(); + } + + // 6. 触发事件 + if (doEvent) { + this.emit('add', pen); + } + + return pen; + } + + private async loadImage(src: string): Promise { + return new Promise((resolve, reject) => { + const img = new Image(); + img.onload = () => resolve(img); + img.onerror = reject; + img.src = src; + }); + } + + private async calculateLayout(pen: Pen): Promise { + // 复杂的布局计算可能需要多帧完成 + return new Promise((resolve) => { + requestAnimationFrame(() => { + this.updatePenBounds(pen); + this.updateSpatialIndex(pen); + resolve(); + }); + }); + } +} +``` + +#### 5.2.2 异步的必要性分析 + +```mermaid +sequenceDiagram + participant User as User Action + participant Service as EditorService + participant Meta2d as Meta2d Engine + participant Canvas as Canvas Context + participant Browser as Browser + + User->>Service: addArea() + Service->>Meta2d: addPen(pen) + + Note over Meta2d: 检查是否需要加载图片资源 + Meta2d->>Browser: 加载图片 (async) + Browser-->>Meta2d: 图片加载完成 + + Note over Meta2d: 等待渲染队列空闲 + Meta2d->>Meta2d: 添加到数据结构 + + Note over Meta2d: 计算元素边界和布局 + Meta2d->>Canvas: 请求重绘 + Canvas->>Browser: requestAnimationFrame + Browser-->>Canvas: 下一帧回调 + + Meta2d-->>Service: 返回创建的元素 + Service->>Service: 调用 bottom() 设置层级 +``` + +### 5.3 性能影响分析 + +#### 5.3.1 异步的性能优势 + +```typescript +// 优势1: 避免阻塞主线程 +// 同步版本 (假设的问题版本) +public addAreaSync(p1: Point, p2: Point): void { + const pen = this.createPen(); + this.addPenSync(pen); // 会阻塞主线程 + this.render(); // 立即渲染,可能导致卡顿 +} + +// 异步版本 (当前实现) +public async addArea(p1: Point, p2: Point): Promise { + const pen = this.createPen(); + await this.addPen(pen); // 非阻塞,允许其他任务执行 + this.bottom(pen); // 确保pen已经正确添加后再操作 +} +``` + +#### 5.3.2 批量操作的性能考虑 + +```typescript +// 当前的批量加载实现 +async #loadSceneAreas(areas?: StandardSceneArea[]): Promise { + if (!areas?.length) return; + await Promise.all( // 并发执行,但可能导致资源竞争 + areas.map(async (v) => { + await this.addArea({ x: v.x, y: v.y }, { x: v.x + v.w, y: v.y + v.h }, v.type, v.id); + }), + ); +} + +// 优化建议: 控制并发数量 +async #loadSceneAreasOptimized(areas?: StandardSceneArea[]): Promise { + if (!areas?.length) return; + + const CONCURRENT_LIMIT = 5; // 限制并发数量 + for (let i = 0; i < areas.length; i += CONCURRENT_LIMIT) { + const batch = areas.slice(i, i + CONCURRENT_LIMIT); + await Promise.all( + batch.map(async (v) => { + await this.addArea( + { x: v.x, y: v.y }, + { x: v.x + v.w, y: v.y + v.h }, + v.type, + v.id + ); + }) + ); + // 每批次之间给主线程喘息时间 + await new Promise(resolve => setTimeout(resolve, 10)); + } +} +``` + +## 6. 技术架构分析 + +### 6.1 核心依赖关系 + +```typescript +// 主要导入依赖 +import { getSceneById, pushSceneById } from '@api/scene'; // 场景API +import { EditorService } from '@core/editor.service'; // 编辑器服务 +import { decodeTextFile, downloadFile, selectFile, textToBlob } from '@core/utils'; // 工具函数 +``` + +### 6.2 组件架构设计 + +#### 6.2.1 状态管理 + +```typescript +// 核心状态定义 +const title = ref(''); // 场景标题 +const editable = ref(false); // 编辑状态 +const show = ref(true); // 卡片显示状态 +const current = ref<{ type: string; id: string }>(); // 当前选中元素 +const container = shallowRef(); // 编辑器容器 +const editor = shallowRef(); // 编辑器服务实例 +``` + +#### 6.2.2 依赖注入系统 + +```typescript +const EDITOR_KEY = Symbol('editor-key'); +provide(EDITOR_KEY, editor); +``` + +使用 Vue 3 的依赖注入机制,将编辑器服务注入到子组件中。 + +### 6.3 EditorService 核心服务分析 + +#### 6.3.1 服务基础 + +```typescript +export class EditorService extends Meta2d { + // 继承自 Meta2d 图形引擎 + // 提供场景编辑的核心功能 +} +``` + +#### 6.3.2 核心方法 + +- **load()**: 加载场景数据到编辑器 +- **save()**: 保存当前场景数据 +- **setState()**: 设置编辑器状态(可编辑/只读) +- **updateRobots()**: 更新机器人数据 +- **addArea()**: 添加区域 +- **deleteById()**: 删除指定元素 + +### 6.4 API 接口设计 + +#### 6.4.1 场景相关API + +```typescript +// 获取场景数据 +export async function getSceneById(id: string): Promise; + +// 推送场景到数据库 +export async function pushSceneById(id: string): Promise; + +// 保存场景数据 +export async function saveSceneById(id: string, json: string, png?: string): Promise; +``` + +#### 6.4.2 文件操作工具 + +```typescript +// 文件选择 +export async function selectFile(accept?: string, limit?: number): Promise; + +// 文件解码 +export async function decodeTextFile(file: File): Promise; + +// 文本转二进制 +export function textToBlob(text: string): Blob | undefined; + +// 文件下载 +export function downloadFile(url: string, name?: string): void; +``` + +## 7. 从零开发实现过程 + +### 7.1 第一步:创建基础组件结构 + +```vue + + + +``` + +### 7.2 第二步:集成编辑器服务 + +```typescript +// 1. 导入编辑器服务 +import { EditorService } from '@core/editor.service'; + +// 2. 创建编辑器实例 +const container = shallowRef(); +const editor = shallowRef(); + +// 3. 组件挂载时初始化编辑器 +onMounted(() => { + editor.value = new EditorService(container.value!); +}); + +// 4. 设置依赖注入 +const EDITOR_KEY = Symbol('editor-key'); +provide(EDITOR_KEY, editor); +``` + +### 7.3 第三步:实现场景数据管理 + +```typescript +// 1. 导入API +import { getSceneById, pushSceneById } from '@api/scene'; + +// 2. 读取场景数据 +const readScene = async () => { + const res = await getSceneById(props.id); + title.value = res?.label ?? ''; + editor.value?.load(res?.json, editable.value); +}; + +// 3. 推送场景数据 +const pushScene = async () => { + const res = await pushSceneById(props.id); + if (!res) return Promise.reject(); + message.success(t('场景推送成功')); + return Promise.resolve(); +}; + +// 4. 监听场景ID变化 +watch( + () => props.id, + () => readScene(), + { immediate: true, flush: 'post' }, +); +``` + +### 7.4 第四步:实现文件导入导出 + +```typescript +// 1. 导入工具函数 +import { decodeTextFile, downloadFile, selectFile, textToBlob } from '@core/utils'; + +// 2. 导入场景文件 +const importScene = async () => { + const file = await selectFile('.scene'); + if (!file?.size) return; + const json = await decodeTextFile(file); + editor.value?.load(json, editable.value); +}; + +// 3. 导出场景文件 +const exportScene = () => { + const json = editor.value?.save(); + if (!json) return; + const blob = textToBlob(json); + if (!blob?.size) return; + const url = URL.createObjectURL(blob); + downloadFile(url, `${title.value || 'unknown'}.scene`); + URL.revokeObjectURL(url); +}; +``` + +### 7.5 第五步:集成管理组件 + +```vue + +``` + +### 7.6 第六步:实现选中元素监听 + +```typescript +// 1. 监听编辑器选中元素 +watch( + () => editor.value?.selected.value[0], + (v) => { + const pen = editor.value?.getPenById(v); + if (pen?.id) { + current.value = { type: pen.name as 'point' | 'line' | 'area', id: pen.id }; + return; + } + if (current.value?.type === 'robot') return; + current.value = undefined; + }, +); + +// 2. 计算选中元素类型 +const isRobot = computed(() => current.value?.type === 'robot'); +const isPoint = computed(() => current.value?.type === 'point'); +const isRoute = computed(() => current.value?.type === 'line'); +const isArea = computed(() => current.value?.type === 'area'); + +// 3. 机器人选择处理 +const selectRobot = (id: string) => { + current.value = { type: 'robot', id }; + editor.value?.inactive(); +}; +``` + +### 7.7 第七步:添加工具栏和详情卡片 + +```vue + +``` + +## 8. 子组件详细分析 + +### 8.1 RobotGroups 组件 + +**功能**: 管理机器人组和单个机器人 +**核心特性**: + +- 机器人组的增删改查 +- 机器人的添加、注册、移除 +- 批量操作支持(全选、批量移除) +- 搜索过滤功能 + +**关键实现**: + +```typescript +// 机器人列表获取 +const robots = computed(() => editor.value.robots.filter(({ label }) => label.includes(keyword.value))); + +// 批量选择管理 +const selected = reactive>(new Set()); +const selectAll = (checked: boolean) => { + if (checked) { + robots.value.forEach(({ id }) => selected.add(id)); + } else { + selected.clear(); + } +}; +``` + +### 8.2 PenGroups 组件 + +**功能**: 管理点位、路线、区域等绘制元素 +**核心特性**: + +- 分类显示不同类型的元素(点位、路线、区域) +- 支持筛选特定类型(如仅显示库区) +- 搜索过滤功能 +- 点击选中功能 + +**关键实现**: + +```typescript +// 点位列表 +const points = computed(() => + editor.value.points.value.filter(({ label }) => label?.includes(keyword.value)), +); + +// 区域列表(按类型分组) +const areas = computed(() => editor.value.areas.value.filter(({ label }) => label?.includes(keyword.value))); +``` + +### 8.3 EditorToolbar 组件 + +**功能**: 提供编辑工具栏 +**核心特性**: + +- 区域添加工具(库区、互斥区、非互斥区) +- 场景保存功能 +- 撤销/重做操作 +- 删除操作 + +**关键实现**: + +```typescript +// 区域添加模式 +const mode = ref(); +watch(editor.value.mouseBrush, (v) => { + if (!mode.value) return; + const [p1, p2] = v ?? []; + if (isEmpty(p1) || isEmpty(p2)) return; + editor.value.addArea(p1, p2, mode.value); + mode.value = undefined; +}); +``` + +## 9. 样式设计分析 + +### 9.1 布局结构 + +- **头部**: 固定高度64px,包含标题和操作按钮 +- **主体**: 左侧面板320px宽度,右侧编辑器自适应 +- **工具栏**: 固定在底部中央,悬浮显示 +- **详情卡片**: 固定在右侧,320px宽度,悬浮显示 + +### 9.2 核心样式 + +```scss +.editor-container { + background-color: transparent !important; +} + +.toolbar-container { + position: fixed; + bottom: 40px; + left: 50%; + z-index: 100; + transform: translateX(-50%); +} + +.card-container { + position: fixed; + top: 80px; + right: 64px; + z-index: 100; + width: 320px; + height: calc(100% - 96px); + overflow: visible; + pointer-events: none; + + & > * { + pointer-events: all; + } +} +``` + +## 10. 维护和调试指南 + +### 10.1 常见问题排查 + +#### 问题1: 场景数据加载失败 + +**排查步骤**: + +1. 检查 `props.id` 是否正确传入 +2. 检查 `getSceneById` API 是否正常响应 +3. 检查编辑器服务是否正确初始化 + +#### 问题2: 编辑器功能异常 + +**排查步骤**: + +1. 检查 `container` 元素是否正确获取 +2. 检查 `EditorService` 是否正确实例化 +3. 检查依赖注入是否正常工作 + +#### 问题3: 文件导入导出失败 + +**排查步骤**: + +1. 检查工具函数是否正确导入 +2. 检查文件格式是否正确 +3. 检查浏览器兼容性 + +### 10.2 性能优化建议 + +1. **使用 shallowRef**: 对于大对象使用 `shallowRef` 避免深度响应式 +2. **组件懒加载**: 使用 `v-if` 控制组件渲染时机 +3. **事件防抖**: 对于频繁触发的事件(如搜索)使用防抖 +4. **内存管理**: 及时清理事件监听器和定时器 + +### 10.3 扩展开发指南 + +#### 添加新的元素类型 + +1. 在 `EditorService` 中添加对应的管理方法 +2. 在 `PenGroups` 组件中添加新的分组 +3. 创建对应的详情卡片组件 +4. 在主组件中添加类型判断逻辑 + +#### 添加新的工具 + +1. 在 `EditorToolbar` 组件中添加工具按钮 +2. 在 `EditorService` 中实现对应功能 +3. 处理工具状态管理和交互逻辑 + +## 11. 总结 + +这个场景编辑器组件是一个功能完整、架构清晰的复杂组件,主要特点: + +1. **模块化设计**: 通过子组件分离不同功能模块 +2. **服务化架构**: 核心逻辑封装在 EditorService 中 +3. **响应式状态管理**: 使用 Vue 3 的响应式系统管理复杂状态 +4. **依赖注入**: 通过 provide/inject 实现服务共享 +5. **文件操作**: 完整的文件导入导出功能 +6. **用户体验**: 良好的交互设计和视觉反馈 +7. **异步机制**: 合理使用异步操作确保渲染性能 +8. **性能优化**: 针对大场景提供多层次优化策略 + +对于维护和扩展这个组件,需要重点关注: + +- EditorService 的 API 设计和实现 +- 各子组件之间的通信机制 +- 状态管理的一致性 +- 性能优化和内存管理 +- 异步操作的正确处理 + +## 12. 高性能技术栈替代方案分析 + +### 12.1 技术栈对比总览 + +```mermaid +graph LR + subgraph "当前方案" + A[Meta2d + Canvas 2D] --> A1[性能瓶颈] + A1 --> A2[大场景卡顿] + A1 --> A3[内存占用高] + A1 --> A4[CPU密集计算] + end + + subgraph "高性能替代方案" + B[WebGL方案] --> B1[GPU加速] + C[WebAssembly方案] --> C1[原生性能] + D[混合方案] --> D1[WebGL + WASM] + E[Web Workers] --> E1[多线程计算] + end + + A --> B + A --> C + A --> D + A --> E +``` + +### 12.2 WebGL高性能渲染方案 + +#### 12.2.1 推荐库选择 + +**1. PixiJS (推荐指数: ⭐⭐⭐⭐⭐)** + +```typescript +// PixiJS 实现高性能场景编辑器 +import * as PIXI from 'pixi.js'; + +class HighPerformanceSceneEditor { + private app: PIXI.Application; + private viewport: Viewport; + private spatialHash: SpatialHash; + private cullingSystem: CullingSystem; + + constructor(container: HTMLElement) { + // 创建高性能应用实例 + this.app = new PIXI.Application({ + width: container.clientWidth, + height: container.clientHeight, + antialias: true, + backgroundColor: 0x1099bb, + powerPreference: 'high-performance', // 强制使用独立显卡 + hello: false, // 禁用PIXI欢迎信息 + }); + + // 启用批量渲染和几何体缓存 + this.app.renderer.plugins.batch.setMaxTextures(32); + this.setupViewport(); + this.initOptimizations(); + } + + private setupViewport(): void { + // 使用 pixi-viewport 实现高性能视口 + this.viewport = new Viewport({ + screenWidth: this.app.screen.width, + screenHeight: this.app.screen.height, + worldWidth: 10000, + worldHeight: 10000, + interaction: this.app.renderer.plugins.interaction, + }); + + this.viewport.drag().pinch().wheel().decelerate().clampZoom({ minScale: 0.1, maxScale: 5 }); + } + + private initOptimizations(): void { + // 1. 启用视锥剔除 + this.cullingSystem = new CullingSystem(this.viewport); + + // 2. 空间哈希优化 + this.spatialHash = new SpatialHash(100); // 网格大小100px + + // 3. 对象池 + this.initObjectPools(); + + // 4. LOD系统 + this.initLODSystem(); + } + + // 批量添加大量元素 - 性能优化版本 + async addElementsBatch(elements: SceneElement[]): Promise { + const BATCH_SIZE = 1000; + const batches = this.chunkArray(elements, BATCH_SIZE); + + for (const batch of batches) { + // 使用 Graphics 批量绘制 + const graphics = new PIXI.Graphics(); + + batch.forEach((element) => { + this.drawElementToGraphics(graphics, element); + this.spatialHash.insert(element); + }); + + this.viewport.addChild(graphics); + + // 让出控制权,避免阻塞UI + await this.nextFrame(); + } + } + + private drawElementToGraphics(graphics: PIXI.Graphics, element: SceneElement): void { + graphics.beginFill(element.color); + switch (element.type) { + case 'point': + graphics.drawCircle(element.x, element.y, element.radius); + break; + case 'area': + graphics.drawRect(element.x, element.y, element.width, element.height); + break; + case 'route': + graphics.moveTo(element.x1, element.y1); + graphics.lineTo(element.x2, element.y2); + break; + } + graphics.endFill(); + } +} + +// 视锥剔除系统 +class CullingSystem { + private viewport: Viewport; + private visibleElements: Set = new Set(); + + constructor(viewport: Viewport) { + this.viewport = viewport; + viewport.on('moved', () => this.updateVisibility()); + viewport.on('zoomed', () => this.updateVisibility()); + } + + updateVisibility(): void { + const bounds = this.viewport.getVisibleBounds(); + + this.viewport.children.forEach((child) => { + const elementBounds = child.getBounds(); + const isVisible = this.boundsIntersect(bounds, elementBounds); + + if (isVisible && !child.visible) { + child.visible = true; + this.visibleElements.add(child); + } else if (!isVisible && child.visible) { + child.visible = false; + this.visibleElements.delete(child); + } + }); + } + + private boundsIntersect(a: PIXI.Rectangle, b: PIXI.Rectangle): boolean { + return !(a.x + a.width < b.x || b.x + b.width < a.x || a.y + a.height < b.y || b.y + b.height < a.y); + } +} +``` + +**性能提升预期**: + +- **渲染性能**: 10-50倍提升 (GPU加速) +- **内存使用**: 减少60-80% (批量渲染) +- **帧率**: 60 FPS 稳定 (支持10000+元素) + +**2. Konva.js (推荐指数: ⭐⭐⭐⭐)** + +```typescript +// Konva.js 高性能实现 +import Konva from 'konva'; + +class KonvaSceneEditor { + private stage: Konva.Stage; + private staticLayer: Konva.Layer; + private dynamicLayer: Konva.Layer; + private transformer: Konva.Transformer; + + constructor(container: HTMLElement) { + this.stage = new Konva.Stage({ + container: container, + width: container.clientWidth, + height: container.clientHeight, + }); + + this.setupLayers(); + this.enableOptimizations(); + } + + private setupLayers(): void { + // 静态层:点位、区域 (低频更新) + this.staticLayer = new Konva.Layer({ + listening: false, // 禁用事件监听提升性能 + }); + + // 动态层:机器人、选中状态 (高频更新) + this.dynamicLayer = new Konva.Layer(); + + this.stage.add(this.staticLayer); + this.stage.add(this.dynamicLayer); + } + + private enableOptimizations(): void { + // 1. 启用缓存 + this.staticLayer.cache(); + + // 2. 优化批量更新 + this.stage.batchDraw = true; + + // 3. 视口裁剪 + this.enableViewportCulling(); + + // 4. 事件委托 + this.setupEventDelegation(); + } + + // 批量添加元素 + addElementsBatch(elements: SceneElement[]): void { + const group = new Konva.Group(); + + elements.forEach((element) => { + const shape = this.createElement(element); + group.add(shape); + }); + + this.staticLayer.add(group); + this.staticLayer.batchDraw(); // 批量绘制 + } + + private enableViewportCulling(): void { + this.stage.on('dragmove wheel', () => { + this.updateVisibleElements(); + }); + } + + private updateVisibleElements(): void { + const viewport = this.getViewportBounds(); + + this.staticLayer.children.forEach((child) => { + const bounds = child.getClientRect(); + child.visible(this.boundsIntersect(viewport, bounds)); + }); + + this.staticLayer.batchDraw(); + } +} +``` + +#### 12.2.2 Three.js 3D扩展方案 + +```typescript +// 为未来3D场景编辑做准备 +import * as THREE from 'three'; + +class ThreeJSSceneEditor { + private scene: THREE.Scene; + private camera: THREE.OrthographicCamera; + private renderer: THREE.WebGLRenderer; + private instancedMeshes: Map = new Map(); + + constructor(container: HTMLElement) { + this.setupRenderer(container); + this.setupScene(); + this.enableInstancing(); // 实例化渲染优化 + } + + private setupRenderer(container: HTMLElement): void { + this.renderer = new THREE.WebGLRenderer({ + antialias: true, + powerPreference: 'high-performance', + }); + + this.renderer.setSize(container.clientWidth, container.clientHeight); + this.renderer.setPixelRatio(window.devicePixelRatio); + + // 启用高性能渲染选项 + this.renderer.shadowMap.enabled = true; + this.renderer.shadowMap.type = THREE.PCFSoftShadowMap; + container.appendChild(this.renderer.domElement); + } + + // 实例化渲染 - 支持数万个相同元素 + private enableInstancing(): void { + const pointGeometry = new THREE.CircleGeometry(1, 8); + const pointMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 }); + + // 创建实例化网格,支持10000个点位 + const pointInstances = new THREE.InstancedMesh(pointGeometry, pointMaterial, 10000); + + this.instancedMeshes.set('points', pointInstances); + this.scene.add(pointInstances); + } + + // 批量更新实例位置 + updatePointsPositions(points: Point[]): void { + const instancedMesh = this.instancedMeshes.get('points'); + if (!instancedMesh) return; + + const matrix = new THREE.Matrix4(); + + points.forEach((point, index) => { + matrix.setPosition(point.x, point.y, 0); + instancedMesh.setMatrixAt(index, matrix); + }); + + instancedMesh.instanceMatrix.needsUpdate = true; + } +} +``` + +### 12.3 WebAssembly超高性能方案 + +#### 12.3.1 Rust + WebAssembly实现 + +```rust +// src/scene_engine.rs +use wasm_bindgen::prelude::*; +use web_sys::CanvasRenderingContext2d; + +#[wasm_bindgen] +pub struct SceneEngine { + elements: Vec, + spatial_grid: SpatialGrid, + viewport: Viewport, +} + +#[wasm_bindgen] +pub struct SceneElement { + id: u32, + x: f64, + y: f64, + element_type: ElementType, + visible: bool, +} + +#[wasm_bindgen] +impl SceneEngine { + #[wasm_bindgen(constructor)] + pub fn new() -> SceneEngine { + SceneEngine { + elements: Vec::with_capacity(100000), // 预分配大容量 + spatial_grid: SpatialGrid::new(100.0), // 100px网格 + viewport: Viewport::new(), + } + } + + // 批量添加元素 - 零拷贝操作 + #[wasm_bindgen] + pub fn add_elements_batch(&mut self, elements_ptr: *const u32, count: usize) { + unsafe { + let elements_slice = std::slice::from_raw_parts(elements_ptr, count * 4); + + for chunk in elements_slice.chunks(4) { + let element = SceneElement { + id: chunk[0], + x: f64::from_bits(chunk[1] as u64), + y: f64::from_bits(chunk[2] as u64), + element_type: ElementType::from_u32(chunk[3]), + visible: true, + }; + + self.spatial_grid.insert(&element); + self.elements.push(element); + } + } + } + + // 高性能视锥剔除 + #[wasm_bindgen] + pub fn update_visibility(&mut self, viewport_x: f64, viewport_y: f64, + viewport_width: f64, viewport_height: f64) -> Vec { + let mut visible_ids = Vec::new(); + + // 使用空间网格快速查询 + let candidates = self.spatial_grid.query( + viewport_x, viewport_y, viewport_width, viewport_height + ); + + for element_id in candidates { + if let Some(element) = self.elements.get_mut(*element_id as usize) { + element.visible = true; + visible_ids.push(element.id); + } + } + + visible_ids + } + + // 并行计算路径 + #[wasm_bindgen] + pub fn calculate_paths_parallel(&self, start_points: &[u32], + end_points: &[u32]) -> Vec { + use rayon::prelude::*; + + start_points.par_iter() + .zip(end_points.par_iter()) + .map(|(start, end)| { + self.calculate_shortest_path(*start, *end) + }) + .flatten() + .collect() + } +} + +// 高性能空间网格 +pub struct SpatialGrid { + cell_size: f64, + cells: std::collections::HashMap<(i32, i32), Vec>, +} + +impl SpatialGrid { + pub fn new(cell_size: f64) -> Self { + Self { + cell_size, + cells: std::collections::HashMap::new(), + } + } + + pub fn insert(&mut self, element: &SceneElement) { + let cell_x = (element.x / self.cell_size) as i32; + let cell_y = (element.y / self.cell_size) as i32; + + self.cells.entry((cell_x, cell_y)) + .or_insert_with(Vec::new) + .push(element.id); + } + + pub fn query(&self, x: f64, y: f64, width: f64, height: f64) -> Vec<&u32> { + let mut results = Vec::new(); + + let start_x = (x / self.cell_size) as i32; + let start_y = (y / self.cell_size) as i32; + let end_x = ((x + width) / self.cell_size) as i32; + let end_y = ((y + height) / self.cell_size) as i32; + + for cell_x in start_x..=end_x { + for cell_y in start_y..=end_y { + if let Some(elements) = self.cells.get(&(cell_x, cell_y)) { + results.extend(elements.iter()); + } + } + } + + results + } +} +``` + +#### 12.3.2 TypeScript集成层 + +```typescript +// TypeScript 集成 WebAssembly +import init, { SceneEngine } from './pkg/scene_engine'; + +class WasmSceneEditor { + private wasmEngine: SceneEngine; + private canvas: HTMLCanvasElement; + private ctx: CanvasRenderingContext2D; + private sharedBuffer: SharedArrayBuffer; + + async init(container: HTMLElement): Promise { + // 初始化 WebAssembly 模块 + await init(); + this.wasmEngine = new SceneEngine(); + + this.setupCanvas(container); + this.setupSharedMemory(); + } + + private setupSharedMemory(): void { + // 使用 SharedArrayBuffer 实现零拷贝数据传输 + this.sharedBuffer = new SharedArrayBuffer(1024 * 1024 * 4); // 4MB + } + + // 批量添加元素 - 超高性能 + addElementsBatch(elements: SceneElement[]): void { + // 将数据写入共享内存 + const view = new Uint32Array(this.sharedBuffer); + let offset = 0; + + elements.forEach((element) => { + view[offset++] = element.id; + view[offset++] = this.doubleToUint32(element.x); + view[offset++] = this.doubleToUint32(element.y); + view[offset++] = element.type; + }); + + // 调用 WASM 函数处理 + this.wasmEngine.add_elements_batch(view.byteOffset, elements.length); + } + + // 高性能渲染循环 + private renderLoop = (): void => { + // WASM 计算可见性 + const visibleIds = this.wasmEngine.update_visibility( + this.viewport.x, + this.viewport.y, + this.viewport.width, + this.viewport.height, + ); + + // 渲染可见元素 + this.renderVisibleElements(visibleIds); + + requestAnimationFrame(this.renderLoop); + }; + + private doubleToUint32(value: number): number { + const buffer = new ArrayBuffer(8); + new Float64Array(buffer)[0] = value; + return new Uint32Array(buffer)[0]; + } +} + +// 性能监控 +class PerformanceMonitor { + private frameCount = 0; + private lastTime = performance.now(); + + update(): void { + this.frameCount++; + const now = performance.now(); + + if (now - this.lastTime >= 1000) { + console.log(`FPS: ${this.frameCount}`); + console.log(`Memory: ${(performance as any).memory?.usedJSHeapSize / 1024 / 1024}MB`); + + this.frameCount = 0; + this.lastTime = now; + } + } +} +``` + +### 12.4 Web Workers多线程优化 + +#### 12.4.1 多线程架构设计 + +```typescript +// 主线程 +class MultiThreadSceneEditor { + private renderWorker: Worker; + private calculationWorker: Worker; + private dataWorker: Worker; + private offscreenCanvas: OffscreenCanvas; + + constructor(container: HTMLElement) { + this.setupWorkers(); + this.setupOffscreenCanvas(container); + } + + private setupWorkers(): void { + // 渲染工作线程 + this.renderWorker = new Worker('./workers/render.worker.js'); + + // 计算工作线程 + this.calculationWorker = new Worker('./workers/calculation.worker.js'); + + // 数据处理工作线程 + this.dataWorker = new Worker('./workers/data.worker.js'); + + this.setupWorkerCommunication(); + } + + private setupOffscreenCanvas(container: HTMLElement): void { + const canvas = document.createElement('canvas'); + canvas.width = container.clientWidth; + canvas.height = container.clientHeight; + container.appendChild(canvas); + + // 传输canvas控制权给worker + this.offscreenCanvas = canvas.transferControlToOffscreen(); + + this.renderWorker.postMessage( + { + type: 'init', + canvas: this.offscreenCanvas, + }, + [this.offscreenCanvas], + ); + } + + // 并行处理大量数据 + async processElementsBatch(elements: SceneElement[]): Promise { + const chunkSize = Math.ceil(elements.length / 3); + + // 并行处理 + const promises = [ + this.processChunk(elements.slice(0, chunkSize), 0), + this.processChunk(elements.slice(chunkSize, chunkSize * 2), 1), + this.processChunk(elements.slice(chunkSize * 2), 2), + ]; + + await Promise.all(promises); + } + + private processChunk(chunk: SceneElement[], workerId: number): Promise { + return new Promise((resolve) => { + const worker = [this.dataWorker, this.calculationWorker, this.renderWorker][workerId]; + + worker.postMessage({ + type: 'process', + data: chunk, + chunkId: workerId, + }); + + worker.addEventListener('message', (e) => { + if (e.data.type === 'processed' && e.data.chunkId === workerId) { + resolve(); + } + }); + }); + } +} + +// 渲染工作线程 (render.worker.ts) +class RenderWorker { + private ctx: OffscreenCanvasRenderingContext2D; + private elementsBuffer: Float32Array; + + constructor() { + self.addEventListener('message', this.handleMessage.bind(this)); + } + + private handleMessage(e: MessageEvent): void { + switch (e.data.type) { + case 'init': + this.ctx = e.data.canvas.getContext('2d'); + break; + + case 'render': + this.renderFrame(e.data.elements, e.data.viewport); + break; + + case 'updateElements': + this.elementsBuffer = new Float32Array(e.data.buffer); + break; + } + } + + private renderFrame(elements: Float32Array, viewport: Viewport): void { + this.ctx.clearRect(0, 0, viewport.width, viewport.height); + + // 批量渲染优化 + this.ctx.save(); + this.ctx.translate(-viewport.x, -viewport.y); + + // 使用 ImageData 直接操作像素 + const imageData = this.ctx.createImageData(viewport.width, viewport.height); + const data = imageData.data; + + // 高性能像素级渲染 + for (let i = 0; i < elements.length; i += 4) { + const x = elements[i]; + const y = elements[i + 1]; + const color = elements[i + 2]; + const type = elements[i + 3]; + + this.drawPixel(data, x, y, color, viewport); + } + + this.ctx.putImageData(imageData, viewport.x, viewport.y); + this.ctx.restore(); + + // 通知主线程渲染完成 + self.postMessage({ type: 'frameRendered' }); + } +} +``` + +### 12.5 性能对比表 + +| 方案 | 渲染性能 | 内存使用 | 开发复杂度 | 兼容性 | 推荐场景 | +| --------------- | ---------- | ---------- | ---------- | ---------- | --------------- | +| **当前Meta2d** | ⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | <1000元素 | +| **PixiJS** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | 1000-10000元素 | +| **Three.js** | ⭐⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐ | ⭐⭐⭐⭐ | 3D场景/复杂效果 | +| **WebAssembly** | ⭐⭐⭐⭐⭐ | ⭐⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐ | >10000元素 | +| **Web Workers** | ⭐⭐⭐⭐ | ⭐⭐⭐⭐ | ⭐⭐ | ⭐⭐⭐⭐ | CPU密集计算 | + +### 12.6 迁移实施建议 + +#### 12.6.1 渐进式迁移策略 + +**阶段1: 立即优化 (1-2周)** + +```typescript +// 在现有Meta2d基础上添加PixiJS渲染层 +class HybridSceneEditor extends EditorService { + private pixiRenderer: PIXI.Application; + private usePixiForLargeScene = false; + + constructor(container: HTMLDivElement) { + super(container); + this.initPixiRenderer(container); + } + + private initPixiRenderer(container: HTMLDivElement): void { + this.pixiRenderer = new PIXI.Application({ + width: container.clientWidth, + height: container.clientHeight, + transparent: true, + powerPreference: 'high-performance', + }); + + // 叠加在Meta2d之上 + container.appendChild(this.pixiRenderer.view); + this.pixiRenderer.view.style.position = 'absolute'; + this.pixiRenderer.view.style.pointerEvents = 'none'; + } + + public override async load(map?: string, editable = false): Promise { + await super.load(map, editable); + + // 检查元素数量,决定使用哪个渲染器 + const totalElements = this.points.value.length + this.routes.value.length + this.areas.value.length; + + if (totalElements > 1000) { + console.log('🚀 切换到PixiJS高性能渲染'); + this.usePixiForLargeScene = true; + this.migrateToPixi(); + } + } + + private migrateToPixi(): void { + // 隐藏Meta2d渲染层 + this.canvas.canvas.style.opacity = '0.1'; + + // 使用PixiJS渲染大量元素 + this.renderWithPixi(); + } +} +``` + +**阶段2: 核心重构 (1-2月)** + +```typescript +// 完全基于PixiJS的新实现 +class NextGenSceneEditor { + private app: PIXI.Application; + private sceneContainer: PIXI.Container; + private quadTree: QuadTree; + private instanceManager: InstanceManager; + + constructor(container: HTMLElement) { + this.setupPixiApp(container); + this.setupOptimizations(); + } + + private setupOptimizations(): void { + // 1. 四叉树空间索引 + this.quadTree = new QuadTree(0, 0, 10000, 10000); + + // 2. 实例化管理器 + this.instanceManager = new InstanceManager(); + + // 3. 批量渲染系统 + this.setupBatchRenderer(); + + // 4. LOD系统 + this.setupLODSystem(); + } + + // 支持10万+元素的批量添加 + async addMassiveElements(elements: SceneElement[]): Promise { + console.time('MassiveElementsAdd'); + + // 使用实例化渲染 + const instancedElements = this.instanceManager.createInstances(elements); + + // 批量添加到四叉树 + elements.forEach((element) => { + this.quadTree.insert(element); + }); + + // 添加到场景 + instancedElements.forEach((instance) => { + this.sceneContainer.addChild(instance); + }); + + console.timeEnd('MassiveElementsAdd'); + console.log(`✅ 成功添加 ${elements.length} 个元素`); + } +} +``` + +**阶段3: WebAssembly增强 (2-3月)** + +```typescript +// 添加WebAssembly计算核心 +class UltimateSceneEditor extends NextGenSceneEditor { + private wasmCore: SceneEngineWasm; + private sharedBuffer: SharedArrayBuffer; + + async init(): Promise { + await super.init(); + + // 初始化WASM核心 + this.wasmCore = await SceneEngineWasm.init(); + + // 设置共享内存 + this.setupSharedMemory(); + } + + // 百万级元素支持 + async loadMegaScene(sceneData: MegaSceneData): Promise { + console.log(`🔥 加载超大场景: ${sceneData.elements.length} 个元素`); + + // WASM并行处理 + const processed = await this.wasmCore.processMegaScene(this.sharedBuffer, sceneData.elements.length); + + // PixiJS渲染 + await this.renderProcessedElements(processed); + + console.log('🎉 超大场景加载完成,性能提升100倍+'); + } +} +``` + +### 12.7 实施成本效益分析 + +#### 12.7.1 开发成本 + +```typescript +interface MigrationCost { + timeWeeks: number; + complexity: 'Low' | 'Medium' | 'High'; + riskLevel: 'Low' | 'Medium' | 'High'; + performanceGain: string; +} + +const migrationOptions: Record = { + pixiJSMigration: { + timeWeeks: 4, + complexity: 'Medium', + riskLevel: 'Low', + performanceGain: '10-50x渲染性能提升', + }, + + webAssemblyCore: { + timeWeeks: 8, + complexity: 'High', + riskLevel: 'Medium', + performanceGain: '100x+计算性能提升', + }, + + webWorkersParallel: { + timeWeeks: 3, + complexity: 'Medium', + riskLevel: 'Low', + performanceGain: '多核心并行处理', + }, + + hybridApproach: { + timeWeeks: 2, + complexity: 'Low', + riskLevel: 'Low', + performanceGain: '保持兼容性的性能提升', + }, +}; +``` + +#### 12.7.2 推荐实施路径 + +**🎯 推荐方案: PixiJS + Web Workers混合架构** + +```typescript +// 最佳性价比方案 +class RecommendedSceneEditor { + private pixiApp: PIXI.Application; + private calculationWorker: Worker; + private renderingOptimized = true; + + // 优势: + // ✅ 10-50倍性能提升 + // ✅ 4周开发周期 + // ✅ 低风险 + // ✅ 向后兼容 + // ✅ 支持1万+元素 + + constructor(container: HTMLElement) { + console.log('🚀 启用推荐高性能方案'); + this.initOptimizedRenderer(container); + } + + async benchmark(): Promise { + const elementCounts = [100, 1000, 5000, 10000, 50000]; + const results: PerformanceReport = {}; + + for (const count of elementCounts) { + const elements = this.generateTestElements(count); + + console.time(`Render ${count} elements`); + await this.addElementsBatch(elements); + console.timeEnd(`Render ${count} elements`); + + results[count] = { + fps: this.measureFPS(), + memory: this.measureMemory(), + renderTime: performance.now(), + }; + } + + return results; + } +} +``` + +### 12.8 总结建议 + +基于您的需求和现有技术栈,我建议采用以下**分阶段实施策略**: + +**🔥 立即实施 (高优先级)** + +1. **PixiJS渲染层**: 4周内实现,性能提升10-50倍 +2. **Web Workers计算**: 并行处理复杂计算 +3. **视口裁剪优化**: 只渲染可见元素 + +**⚡ 中期规划 (中优先级)** + +1. **WebAssembly核心**: 处理超大规模场景 +2. **四叉树空间索引**: 优化元素查找 +3. **LOD渲染系统**: 根据缩放级别调整详度 + +**🚀 长期愿景 (低优先级)** + +1. **Three.js 3D扩展**: 支持3D场景编辑 +2. **GPU计算着色器**: 最极致的性能优化 +3. **WebXR支持**: VR/AR场景编辑 + +这样的技术栈升级可以让您的场景编辑器: + +- 支持**10万+元素**的超大场景 +- 保持**60 FPS**稳定渲染 +- 减少**80%**的内存占用 +- 提供更好的用户体验 diff --git a/机器人运动监控组件详细分析.md b/机器人运动监控组件详细分析.md new file mode 100644 index 0000000..0abccaa --- /dev/null +++ b/机器人运动监控组件详细分析.md @@ -0,0 +1,632 @@ +# 机器人运动监控组件详细分析 + +## 1. 组件架构概述 + +### 1.1 核心组件结构 + +`movement-supervision.vue` 是机器人运动监控的主要组件,负责实时显示机器人在场景中的位置和状态。 + +```typescript +// 组件核心属性 +type Props = { + sid: string; // 场景ID + id?: string; // 机器人组ID(可选) +}; +``` + +### 1.2 依赖服务架构 + +- **EditorService**: 基于Meta2D的场景编辑器服务 +- **WebSocket服务**: 提供实时数据通信 +- **场景API服务**: 处理场景数据的增删改查 + +## 2. 组件生命周期详解 + +### 2.1 组件初始化流程 + +```typescript +onMounted(async () => { + await readScene(); // 步骤1: 加载场景数据 + await editor.value?.initRobots(); // 步骤2: 初始化机器人 + await monitorScene(); // 步骤3: 建立WebSocket监控 +}); +``` + +#### 步骤1: readScene() - 场景数据加载 + +```typescript +const readScene = async () => { + const res = props.id ? await getSceneByGroupId(props.id, props.sid) : await getSceneById(props.sid); + title.value = res?.label ?? ''; + editor.value?.load(res?.json); +}; +``` + +**关键问题点**: 每个页面实例都独立调用API获取场景数据,可能导致: + +- 不同时间点获取的数据版本不一致 +- 网络延迟造成的数据获取时差 +- 场景数据在获取期间被其他页面修改 + +#### 步骤2: initRobots() - 机器人初始化 + +```typescript +public async initRobots(): Promise { + await Promise.all( + this.robots.map(async ({ id, label, type }) => { + const pen: MapPen = { + ...this.#mapRobotImage(type, true), + id, + name: 'robot', + tags: ['robot'], + x: 0, // 关键: 初始位置固定为(0,0) + y: 0, // 关键: 初始位置固定为(0,0) + width: 74, + height: 74, + lineWidth: 1, + robot: { type }, + visible: false, // 关键: 初始状态为不可见 + text: label, + textTop: -24, + whiteSpace: 'nowrap', + ellipsis: false, + locked: LockState.Disable, + }; + await this.addPen(pen, false, true, true); + }), + ); +} +``` + +**问题分析**: + +- 所有机器人初始位置都设为`(0,0)` +- 初始状态为`visible: false`,需要WebSocket数据才能显示 +- 如果WebSocket连接延迟,不同页面的机器人可能长时间处于不可见状态 + +#### 步骤3: monitorScene() - WebSocket监控建立 + +```typescript +const monitorScene = async () => { + client.value?.close(); // 关闭之前的连接 + const ws = await monitorSceneById(props.sid); // 创建新连接 + if (isNil(ws)) return; + + ws.onmessage = (e) => { + const { id, x, y, active, angle, path, ...rest } = JSON.parse(e.data || '{}'); + + if (!editor.value?.checkRobotById(id)) return; // 验证机器人存在 + + editor.value?.updateRobot(id, rest); // 更新机器人基本信息 + + if (isNil(x) || isNil(y)) { + // 关键逻辑: 无位置信息时隐藏机器人 + editor.value.updatePen(id, { visible: false }); + } else { + // 关键逻辑: 有位置信息时更新位置并显示 + editor.value.refreshRobot(id, { x, y, active, angle, path }); + } + }; + client.value = ws; +}; +``` + +## 3. 机器人实时移动机制深度分析 + +### 3.1 WebSocket消息处理流程 + +每当接收到WebSocket消息时,会执行以下处理逻辑: + +1. **消息解析**: 将JSON字符串解析为`RobotRealtimeInfo`对象 +2. **机器人验证**: 调用`checkRobotById(id)`验证机器人是否存在 +3. **基本信息更新**: 调用`updateRobot(id, rest)`更新电量、状态等信息 +4. **位置处理**: 根据坐标是否存在进行不同处理 + +### 3.2 位置更新核心逻辑: refreshRobot() + +```typescript +public refreshRobot(id: RobotInfo['id'], info: Partial): void { + const pen = this.getPenById(id); + const { rotate: or, robot } = pen ?? {}; + if (!robot?.type) return; + + // 获取当前机器人位置 + const { x: ox, y: oy } = this.getPenRect(pen!); + + // 解析新的位置信息(默认值为37,37是机器人中心点) + const { x: cx = 37, y: cy = 37, active, angle, path: points } = info; + + // 关键坐标转换: 从中心点坐标转换为左上角坐标 + const x = cx - 37; // 机器人宽度74,中心偏移37 + const y = cy - 37; // 机器人高度74,中心偏移37 + + const rotate = angle ?? or; // 角度更新 + + // 路径坐标转换 + const path = + points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? // 新路径相对于机器人中心 + robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); // 旧路径坐标调整 + + const o = { ...robot, ...omitBy({ active, path }, isNil) }; + + if (isNil(active)) { + // active为null时,只更新位置不改变图标 + this.setValue( + { id, x, y, rotate, robot: o, visible: true }, + { render: true, history: false, doEvent: false } + ); + } else { + // active有值时,同时更新图标状态(运行/停止状态图标不同) + this.setValue( + { id, ...this.#mapRobotImage(robot.type, active), x, y, rotate, robot: o, visible: true }, + { render: true, history: false, doEvent: false } + ); + } +} +``` + +### 3.3 机器人图标映射逻辑 + +```typescript +#mapRobotImage( + type: RobotType, + active?: boolean, +): Required> { + const theme = this.data().theme; + const image = import.meta.env.BASE_URL + + (active ? `/robot/${type}-active-${theme}.png` : `/robot/${type}-${theme}.png`); + return { + image, + iconWidth: 34, + iconHeight: 54, + iconTop: -5 + }; +} +``` + +### 3.4 机器人绘制函数 + +```typescript +function drawRobot(ctx: CanvasRenderingContext2D, pen: MapPen): void { + const theme = sTheme.editor; + const { lineWidth: s = 1 } = pen.calculative ?? {}; + const { x = 0, y = 0, width: w = 0, height: h = 0, rotate: deg = 0 } = pen.calculative?.worldRect ?? {}; + const { active, path } = pen.robot ?? {}; + + if (!active) return; // 关键: 非活跃状态不绘制路径 + + const ox = x + w / 2; // 机器人中心X坐标 + const oy = y + h / 2; // 机器人中心Y坐标 + + ctx.save(); + // 绘制机器人本体(椭圆) + ctx.ellipse(ox, oy, w / 2, h / 2, 0, 0, Math.PI * 2); + ctx.fillStyle = get(theme, 'robot.fill') ?? ''; + ctx.fill(); + ctx.strokeStyle = get(theme, 'robot.stroke') ?? ''; + ctx.stroke(); + + // 绘制运动路径 + if (path?.length) { + ctx.strokeStyle = get(theme, 'robot.line') ?? ''; + ctx.lineCap = 'round'; + ctx.lineWidth = s * 4; + ctx.setLineDash([s * 5, s * 10]); // 虚线样式 + ctx.translate(ox, oy); + ctx.rotate((-deg * Math.PI) / 180); // 根据机器人角度旋转 + + // 绘制路径线条 + ctx.beginPath(); + ctx.moveTo(0, 0); + path.forEach((d) => ctx.lineTo(d.x * s, d.y * s)); + ctx.stroke(); + + // 绘制路径终点箭头 + const { x: ex1 = 0, y: ey1 = 0 } = nth(path, -1) ?? {}; + const { x: ex2 = 0, y: ey2 = 0 } = nth(path, -2) ?? {}; + const r = Math.atan2(ey1 - ey2, ex1 - ex2) + Math.PI; + ctx.setLineDash([0]); + ctx.translate(ex1 * s, ey1 * s); + ctx.beginPath(); + ctx.moveTo(Math.cos(r + Math.PI / 4) * s * 10, Math.sin(r + Math.PI / 4) * s * 10); + ctx.lineTo(0, 0); + ctx.lineTo(Math.cos(r - Math.PI / 4) * s * 10, Math.sin(r - Math.PI / 4) * s * 10); + ctx.stroke(); + ctx.setTransform(1, 0, 0, 1, 0, 0); + } + ctx.restore(); +} +``` + +## 4. 多页面位置不一致问题深度分析 + +### 4.1 根本原因:缺乏全局状态同步机制 + +每个页面实例都是完全独立的,具体表现为: + +1. **独立的EditorService实例** + + - 每个页面创建独立的`new EditorService(container.value!)` + - 各自维护独立的机器人状态映射`#robotMap` + - 无法共享机器人位置信息 + +2. **独立的WebSocket连接** + - 每个页面调用`monitorSceneById(props.sid)`创建独立连接 + - 服务器可能向不同连接推送不同时间点的数据 + - 网络延迟导致消息到达时间不同 + +### 4.2 具体问题场景分析 + +#### 场景1: 初始化时间差异 + +```typescript +// 页面A在时间T1执行 +onMounted(async () => { + await readScene(); // T1时刻的场景数据 + await initRobots(); // 创建机器人,位置(0,0),visible:false + await monitorScene(); // T1+100ms建立WebSocket +}); + +// 页面B在时间T2执行(T2 > T1) +onMounted(async () => { + await readScene(); // T2时刻的场景数据(可能已更新) + await initRobots(); // 创建机器人,位置(0,0),visible:false + await monitorScene(); // T2+80ms建立WebSocket +}); +``` + +**结果**: 两个页面获取的初始场景数据可能不同,机器人列表或配置存在差异。 + +#### 场景2: WebSocket消息时序差异 + +```typescript +// WebSocket消息处理逻辑 +ws.onmessage = (e) => { + const { id, x, y, active, angle, path, ...rest } = JSON.parse(e.data || '{}'); + + if (isNil(x) || isNil(y)) { + // 关键问题: 无坐标消息会隐藏机器人 + editor.value.updatePen(id, { visible: false }); + } else { + editor.value.refreshRobot(id, { x, y, active, angle, path }); + } +}; +``` + +**问题分析**: + +- 页面A先收到有坐标的消息,机器人显示在位置(100, 200) +- 页面B后收到无坐标的消息,机器人被隐藏 +- 页面C收到旧的坐标消息,机器人显示在位置(80, 150) + +#### 场景3: 坐标转换精度问题 + +```typescript +// refreshRobot中的坐标转换 +const { x: cx = 37, y: cy = 37, active, angle, path: points } = info; +const x = cx - 37; // 默认值37导致的问题 +const y = cy - 37; + +// 当服务器发送的坐标为null/undefined时 +// cx和cy都会使用默认值37,导致机器人位置为(0,0) +``` + +**问题**: 不同页面接收到的消息中坐标字段可能为`null`、`undefined`或有效数值,默认值处理导致位置计算不一致。 + +#### 场景4: 机器人状态检查差异 + +```typescript +if (!editor.value?.checkRobotById(id)) return; + +// checkRobotById实现 +public checkRobotById(id: RobotInfo['id']): boolean { + return this.#robotMap.has(id); +} +``` + +**问题**: 不同页面的`#robotMap`内容可能不同,导致某些页面忽略特定机器人的更新消息。 + +### 4.3 路径绘制不一致问题 + +```typescript +// 路径坐标转换逻辑 +const path = + points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? // 新路径处理 + robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); // 旧路径处理 +``` + +**问题分析**: + +1. 新路径使用`p.x - cx, p.y - cy`进行坐标转换 +2. 旧路径使用`p.x + ox! - x, p.y + oy! - y`进行坐标转换 +3. 两种转换方式在特定情况下可能产生不同结果 +4. 不同页面可能处于新旧路径的不同阶段 + +### 4.4 渲染状态不同步 + +```typescript +// setValue方法的渲染参数 +this.setValue( + { id, x, y, rotate, robot: o, visible: true }, + { render: true, history: false, doEvent: false }, // 立即渲染,不记录历史 +); +``` + +**问题**: + +- `render: true`表示立即重新渲染 +- 不同页面的渲染时机不同步 +- 可能出现某个页面正在渲染时收到新消息的情况 + +## 5. 解决方案详细设计 + +### 5.1 方案一: 全局状态管理器 + +```typescript +/** + * 全局机器人状态管理器 + * 单例模式,确保所有页面共享同一份状态 + */ +class GlobalRobotStateManager { + private static instance: GlobalRobotStateManager; + + // 存储所有机器人的最新状态 + private robotStates = new Map(); + + // 订阅者列表,用于通知状态变化 + private subscribers = new Set<(robotId: string, info: RobotRealtimeInfo) => void>(); + + // 连接管理,避免重复连接 + private connections = new Map(); + + static getInstance(): GlobalRobotStateManager { + if (!this.instance) { + this.instance = new GlobalRobotStateManager(); + } + return this.instance; + } + + /** + * 订阅机器人状态变化 + */ + subscribe(callback: (robotId: string, info: RobotRealtimeInfo) => void): () => void { + this.subscribers.add(callback); + + // 立即推送当前所有机器人状态 + this.robotStates.forEach((info, robotId) => { + callback(robotId, info); + }); + + // 返回取消订阅函数 + return () => this.subscribers.delete(callback); + } + + /** + * 更新机器人状态并通知所有订阅者 + */ + updateRobotState(robotId: string, info: RobotRealtimeInfo): void { + // 合并状态更新 + const currentState = this.robotStates.get(robotId) || ({} as RobotRealtimeInfo); + const newState = { ...currentState, ...info }; + + this.robotStates.set(robotId, newState); + + // 通知所有订阅者 + this.subscribers.forEach((callback) => { + try { + callback(robotId, newState); + } catch (error) { + console.error('机器人状态更新回调执行失败:', error); + } + }); + } + + /** + * 获取或创建WebSocket连接(复用连接) + */ + async getOrCreateConnection(sceneId: string): Promise { + // 检查现有连接 + const existingConnection = this.connections.get(sceneId); + if (existingConnection && existingConnection.readyState === WebSocket.OPEN) { + return existingConnection; + } + + try { + const ws = await monitorSceneById(sceneId); + if (!ws) return null; + + // 设置消息处理 + ws.onmessage = (e) => { + try { + const robotInfo = JSON.parse(e.data || '{}') as RobotRealtimeInfo; + this.updateRobotState(robotInfo.id, robotInfo); + } catch (error) { + console.error('WebSocket消息解析失败:', error); + } + }; + + // 设置连接关闭处理 + ws.onclose = () => { + this.connections.delete(sceneId); + }; + + // 存储连接 + this.connections.set(sceneId, ws); + return ws; + } catch (error) { + console.error('创建WebSocket连接失败:', error); + return null; + } + } +} +``` + +### 5.2 方案二: 改进的组件实现 + +```typescript +// 改进后的movement-supervision.vue核心逻辑 + +``` + +### 5.3 方案三: EditorService增强 + +```typescript +// 为EditorService添加状态缓存和同步机制 +export class EditorService extends Meta2d { + // 添加状态缓存 + private robotStateCache = new Map(); + + /** + * 改进的坐标转换方法 + */ + private normalizeCoordinates(info: Partial): { x: number; y: number } | null { + const { x: cx, y: cy } = info; + + // 严格的坐标验证 + if (typeof cx !== 'number' || typeof cy !== 'number' || isNaN(cx) || isNaN(cy) || cx < 0 || cy < 0) { + return null; // 返回null表示无效坐标 + } + + // 坐标转换:从中心点转换为左上角 + return { + x: cx - 37, // 机器人宽度74,中心偏移37 + y: cy - 37, // 机器人高度74,中心偏移37 + }; + } + + /** + * 改进的refreshRobot方法 + */ + public refreshRobot(id: RobotInfo['id'], info: Partial): void { + const pen = this.getPenById(id); + const { rotate: or, robot } = pen ?? {}; + if (!robot?.type) return; + + // 使用改进的坐标转换 + const coords = this.normalizeCoordinates(info); + + // 无效坐标处理 + if (!coords) { + this.setValue({ id, visible: false }, { render: true, history: false, doEvent: false }); + return; + } + + const { x, y } = coords; + const { active, angle, path: points } = info; + const rotate = angle ?? or; + + // 路径处理优化 + let path: Point[] | undefined; + if (points && Array.isArray(points)) { + // 新路径:相对于机器人中心的坐标 + path = points.map((p) => ({ + x: (p.x || 0) - (info.x || 37), + y: (p.y || 0) - (info.y || 37), + })); + } else if (robot.path) { + // 保持原有路径,但需要调整坐标 + const { x: ox, y: oy } = this.getPenRect(pen!); + path = robot.path.map((p) => ({ + x: p.x + ox - x, + y: p.y + oy - y, + })); + } + + const robotState = { ...robot, ...omitBy({ active, path }, isNil) }; + + // 根据active状态决定渲染方式 + if (typeof active === 'boolean') { + // 有明确的活跃状态,更新图标 + this.setValue( + { + id, + ...this.#mapRobotImage(robot.type, active), + x, + y, + rotate, + robot: robotState, + visible: true, + }, + { render: true, history: false, doEvent: false }, + ); + } else { + // 无活跃状态信息,只更新位置 + this.setValue( + { id, x, y, rotate, robot: robotState, visible: true }, + { render: true, history: false, doEvent: false }, + ); + } + } +} +``` + +## 6. 性能优化建议 + +### 6.1 渲染优化 + +- 使用`requestAnimationFrame`批量处理渲染更新 +- 实现视口裁剪,只渲染可见区域的机器人 +- 添加机器人状态变化的diff检测,避免无效渲染 + +### 6.2 内存管理 + +- 定期清理过期的机器人状态缓存 +- 使用WeakMap存储临时状态,避免内存泄漏 +- 在组件卸载时正确清理WebSocket连接和事件监听器 + +### 6.3 网络优化 + +- 实现WebSocket连接池,复用连接 +- 添加消息压缩,减少网络传输量 +- 使用心跳机制检测连接状态 + +## 7. 总结 + +机器人运动监控组件的多页面位置不一致问题主要源于: + +1. **架构设计缺陷**: 缺乏全局状态管理,每个页面独立维护状态 +2. **WebSocket连接独立性**: 多个连接可能接收到不同时间点的数据 +3. **初始化时序问题**: 不同页面的初始化时间不同,导致状态基线不一致 +4. **坐标转换逻辑**: 默认值处理和坐标转换在边界情况下存在问题 +5. **状态验证不足**: 缺乏对接收数据的有效性验证 + +通过实施全局状态管理、WebSocket连接复用、状态缓存机制和坐标转换优化等解决方案,可以有效解决这些问题,确保多页面间机器人位置的一致性。 diff --git a/编辑器服务核心架构分析.md b/编辑器服务核心架构分析.md new file mode 100644 index 0000000..82386c0 --- /dev/null +++ b/编辑器服务核心架构分析.md @@ -0,0 +1,677 @@ +# 编辑器服务核心架构分析 + +## 1. 概述 + +`EditorService` 是整个场景编辑器的核心服务类,继承自 `Meta2d` 图形引擎。它负责管理场景中的所有元素(机器人、点位、路线、区域),处理用户交互,以及场景数据的序列化和反序列化。 + +```typescript +export class EditorService extends Meta2d { + // 继承 Meta2d 获得强大的 2D 图形渲染能力 +} +``` + +## 2. 核心架构分析 + +### 2.1 继承架构 + +``` +EditorService + ↓ 继承 +Meta2d (第三方图形引擎) + ↓ 提供 +- Canvas 渲染能力 +- 图形元素管理 +- 事件系统 +- 坐标变换 +- 撤销重做 +``` + +### 2.2 核心组成模块 + +1. **场景文件管理** - 序列化/反序列化 +2. **机器人管理** - 机器人组和个体管理 +3. **点位管理** - 各种类型点位的创建和管理 +4. **路线管理** - 连接点位的路径管理 +5. **区域管理** - 矩形区域的创建和管理 +6. **实时交互** - 鼠标事件处理和状态管理 +7. **自定义绘制** - Canvas 绘制函数 +8. **事件监听** - 编辑器状态变化监听 + +## 3. 场景文件管理详解 + +### 3.1 场景数据结构 + +```typescript +type StandardScene = { + robotGroups?: RobotGroup[]; // 机器人组 + robots?: RobotInfo[]; // 机器人列表 + points?: StandardScenePoint[]; // 点位数据 + routes?: StandardSceneRoute[]; // 路线数据 + areas?: StandardSceneArea[]; // 区域数据 + blocks?: any[]; // 其他块数据 +}; +``` + +### 3.2 场景加载过程(为什么场景文件能生成对应区域) + +#### 3.2.1 加载入口函数 + +```typescript +public async load(map?: string, editable = false, detail?: Partial): Promise { + // 1. 解析 JSON 字符串为场景对象 + const scene: StandardScene = map ? JSON.parse(map) : {}; + + // 2. 如果有组详情,优先使用组数据 + if (!isEmpty(detail?.group)) { + scene.robotGroups = [detail.group]; + scene.robots = detail.robots; + } + + // 3. 提取各类数据 + const { robotGroups, robots, points, routes, areas } = scene; + + // 4. 初始化编辑器 + this.open(); // 打开 Meta2d 画布 + this.setState(editable); // 设置编辑状态 + + // 5. 按顺序加载各类元素 + this.#loadRobots(robotGroups, robots); // 加载机器人 + await this.#loadScenePoints(points); // 加载点位 + this.#loadSceneRoutes(routes); // 加载路线 + await this.#loadSceneAreas(areas); // 加载区域 ⭐ + + // 6. 清空历史记录 + this.store.historyIndex = undefined; + this.store.histories = []; +} +``` + +#### 3.2.2 区域加载详细过程 + +```typescript +async #loadSceneAreas(areas?: StandardSceneArea[]): Promise { + if (!areas?.length) return; + + // 并行处理所有区域 + await Promise.all( + areas.map(async (v) => { + // 1. 从场景数据中提取区域信息 + const { id, name, desc, x, y, w, h, type, points, routes, properties } = v; + + // 2. 调用 addArea 方法在画布上创建实际的图形对象 + await this.addArea( + { x, y }, // 左上角坐标 + { x: x + w, y: y + h }, // 右下角坐标 + type, // 区域类型 + id // 区域ID + ); + + // 3. 设置区域的详细属性 + this.setValue( + { + id, + label: name, // 显示名称 + desc, // 描述 + properties, // 自定义属性 + area: { type, points, routes } // 区域特定数据 + }, + { render: false, history: false, doEvent: false } + ); + }) + ); +} +``` + +**关键理解点**: + +- 场景文件中的 `areas` 数组包含了所有区域的完整信息 +- 每个区域包含位置 `(x, y, w, h)`、类型 `type`、关联的点位和路线 +- `addArea` 方法负责在 Canvas 上创建实际的可视化图形 +- `setValue` 方法设置图形对象的业务属性 + +## 4. 区域绘制原理详解(为什么可以在页面画一个区域) + +### 4.1 鼠标事件监听系统 + +```typescript +// 鼠标事件主题 +readonly #mouse$$ = new Subject<{ type: 'click' | 'mousedown' | 'mouseup'; value: Point }>(); + +// 点击事件流 +public readonly mouseClick = useObservable( + this.#mouse$$.pipe( + filter(({ type }) => type === 'click'), + debounceTime(100), + map(({ value }) => value), + ), +); + +// 拖拽事件流 ⭐ 关键!这是画区域的核心 +public readonly mouseBrush = useObservable<[Point, Point]>( + this.#mouse$$.pipe( + filter(({ type }) => type === 'mousedown'), // 监听鼠标按下 + switchMap(({ value: s }) => + this.#mouse$$.pipe( + filter(({ type }) => type === 'mouseup'), // 监听鼠标抬起 + map(({ value: e }) => <[Point, Point]>[s, e]), // 返回起始和结束点 + ), + ), + ), +); +``` + +### 4.2 工具栏组件中的区域创建监听 + +```typescript +// 在 EditorToolbar 组件中 +const mode = ref(); + +// 监听鼠标拖拽事件 +watch(editor.value.mouseBrush, (v) => { + if (!mode.value) return; // 如果没有选择区域工具,不处理 + const [p1, p2] = v ?? []; // 获取起始点和结束点 + if (isEmpty(p1) || isEmpty(p2)) return; // 验证点位有效性 + + // 调用编辑器服务创建区域 ⭐ + editor.value.addArea(p1, p2, mode.value); + mode.value = undefined; // 重置工具状态 +}); +``` + +### 4.3 addArea 方法详细实现 + +```typescript +public async addArea(p1: Point, p2: Point, type = MapAreaType.库区, id?: string) { + // 1. 获取当前缩放比例 + const scale = this.data().scale ?? 1; + + // 2. 计算区域宽高 + const w = Math.abs(p1.x - p2.x); + const h = Math.abs(p1.y - p2.y); + + // 3. 最小尺寸检查(防止创建过小的区域) + if (w * scale < 50 || h * scale < 60) return; + + // 4. 准备关联数据 + const points = new Array(); + const routes = new Array(); + + if (!id) { + id = s8(); // 生成唯一ID + const selected = this.store.active; // 获取当前选中的元素 + + // 5. 根据区域类型自动关联相关元素 + switch (type) { + case MapAreaType.库区: + // 库区只关联动作点 + selected?.filter(({ point }) => point?.type === MapPointType.动作点) + .forEach(({ id }) => points.push(id!)); + break; + case MapAreaType.互斥区: + // 互斥区关联所有点位和路线 + selected?.filter(({ point }) => point?.type) + .forEach(({ id }) => points.push(id!)); + selected?.filter(({ route }) => route?.type) + .forEach(({ id }) => routes.push(id!)); + break; + case MapAreaType.非互斥区: + // 非互斥区只关联点位 + selected?.filter(({ point }) => point?.type) + .forEach(({ id }) => points.push(id!)); + break; + } + } + + // 6. 创建区域图形对象 + const pen: MapPen = { + id, + name: 'area', // 图形类型标识 + tags: ['area', `area-${type}`], // 标签用于查找和分类 + label: `A${id}`, // 显示标签 + x: Math.min(p1.x, p2.x), // 左上角 X + y: Math.min(p1.y, p2.y), // 左上角 Y + width: w, // 宽度 + height: h, // 高度 + lineWidth: 1, // 边框宽度 + area: { type, points, routes }, // 区域业务数据 + locked: LockState.DisableMoveScale, // 锁定状态(禁止移动缩放) + }; + + // 7. 添加到画布并设置层级 + const area = await this.addPen(pen, true, true, true); + this.bottom(area); // 将区域放到最底层 +} +``` + +**关键理解点**: + +1. **事件流处理**:通过 RxJS 的事件流来处理鼠标拖拽 +2. **坐标计算**:将鼠标坐标转换为画布坐标系中的区域 +3. **图形对象创建**:创建符合 Meta2d 要求的图形对象 +4. **层级管理**:区域作为背景层,放在最底层 +5. **状态管理**:自动关联当前选中的相关元素 + +## 5. 自定义绘制系统 + +### 5.1 绘制函数注册 + +```typescript +#register() { + // 注册基础图形 + this.register({ line: () => new Path2D() }); + + // 注册自定义绘制函数 ⭐ + this.registerCanvasDraw({ + point: drawPoint, // 点位绘制 + line: drawLine, // 路线绘制 + area: drawArea, // 区域绘制 ⭐ + robot: drawRobot // 机器人绘制 + }); + + // 注册锚点 + this.registerAnchors({ point: anchorPoint }); + + // 注册线条绘制函数 + this.addDrawLineFn('bezier2', lineBezier2); + this.addDrawLineFn('bezier3', lineBezier3); +} +``` + +### 5.2 区域绘制函数详解 + +```typescript +function drawArea(ctx: CanvasRenderingContext2D, pen: MapPen): void { + // 1. 获取主题配置 + const theme = sTheme.editor; + + // 2. 获取绘制参数 + const { active, fontSize = 14, lineHeight = 1.5, fontFamily } = pen.calculative ?? {}; + const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; + const { type } = pen.area ?? {}; + const { label = '' } = pen ?? {}; + + // 3. 开始绘制 + ctx.save(); + + // 4. 绘制矩形区域 + ctx.rect(x, y, w, h); + + // 5. 填充颜色(根据区域类型) + ctx.fillStyle = get(theme, `area.fill-${type}`) ?? ''; + ctx.fill(); + + // 6. 绘制边框(根据激活状态) + ctx.strokeStyle = get(theme, active ? 'area.strokeActive' : `area.stroke-${type}`) ?? ''; + ctx.stroke(); + + // 7. 绘制标签文字 + ctx.fillStyle = get(theme, 'color') ?? ''; + ctx.font = `${fontSize}px/${lineHeight} ${fontFamily}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); + + ctx.restore(); +} +``` + +**关键理解点**: + +- Canvas 2D API 直接绘制矩形和文字 +- 主题系统提供颜色配置 +- 根据区域类型和激活状态使用不同的样式 +- 文字标签显示在区域上方 + +## 6. 响应式状态管理 + +### 6.1 数据流设计 + +```typescript +// 变化事件主题 +readonly #change$$ = new Subject(); + +// 区域列表响应式数据 +public readonly areas = useObservable( + this.#change$$.pipe( + filter((v) => v), // 只响应数据变化事件 + debounceTime(100), // 防抖处理 + map(() => this.find('area')), // 查找所有区域 + ), + { initialValue: new Array() }, +); + +// 当前选中元素 +public readonly current = useObservable( + this.#change$$.pipe( + debounceTime(100), + map(() => clone(this.store.active?.[0])), + ), +); +``` + +### 6.2 事件监听系统 + +```typescript +#listen(e: unknown, v: any) { + switch (e) { + case 'opened': + this.#load(sTheme.theme); + this.#change$$.next(true); // 触发数据更新 + break; + + case 'add': + this.#change$$.next(true); // 元素添加后更新 + break; + + case 'delete': + this.#onDelete(v); + this.#change$$.next(true); // 元素删除后更新 + break; + + case 'update': + case 'valueUpdate': + this.#change$$.next(true); // 元素更新后更新 + break; + + case 'active': + case 'inactive': + this.#change$$.next(false); // 选择状态变化 + break; + + case 'click': + case 'mousedown': + case 'mouseup': + // 将鼠标事件传递给事件流 + this.#mouse$$.next({ type: e, value: pick(v, 'x', 'y') }); + break; + } +} +``` + +## 7. 场景保存原理 + +### 7.1 保存入口函数 + +```typescript +public save(): string { + // 1. 构建标准场景对象 + const scene: StandardScene = { + robotGroups: this.robotGroups.value, + robots: this.robots, + // 2. 将画布上的图形对象转换为标准格式 + points: this.points.value.map((v) => this.#mapScenePoint(v)).filter((v) => !isNil(v)), + routes: this.routes.value.map((v) => this.#mapSceneRoute(v)).filter((v) => !isNil(v)), + areas: this.areas.value.map((v) => this.#mapSceneArea(v)).filter((v) => !isNil(v)), // ⭐ + blocks: [], + }; + + // 3. 序列化为 JSON 字符串 + return JSON.stringify(scene); +} +``` + +### 7.2 区域数据映射 + +```typescript +#mapSceneArea(pen: MapPen): StandardSceneArea | null { + if (!pen.id || isEmpty(pen.area)) return null; + + // 1. 提取基础信息 + const { id, label, desc, properties } = pen; + const { type, points, routes } = pen.area; + + // 2. 获取区域的实际位置和尺寸 + const { x, y, width, height } = this.getPenRect(pen); + + // 3. 构建标准区域对象 + const area: StandardSceneArea = { + id, + name: label || id, + desc, + x, // 左上角 X 坐标 + y, // 左上角 Y 坐标 + w: width, // 宽度 + h: height, // 高度 + type, // 区域类型 + config: {}, + properties, + }; + + // 4. 根据区域类型设置关联数据 + if (MapAreaType.库区 === type) { + // 库区只保存动作点 + area.points = points?.filter((v) => + this.getPenById(v)?.point?.type === MapPointType.动作点 + ); + } + + if ([MapAreaType.互斥区, MapAreaType.非互斥区].includes(type)) { + // 互斥区和非互斥区保存所有非禁行点 + area.points = points?.filter((v) => { + const { point } = this.getPenById(v) ?? {}; + if (isNil(point)) return false; + if (point.type === MapPointType.禁行点) return false; + return true; + }); + } + + if (MapAreaType.互斥区 === type) { + // 互斥区还要保存关联的路线 + area.routes = routes?.filter((v) => !isEmpty(this.getPenById(v)?.area)); + } + + return area; +} +``` + +## 8. 机器人管理系统 + +### 8.1 机器人数据结构 + +```typescript +// 机器人映射表(响应式) +readonly #robotMap = reactive>(new Map()); + +// 机器人组流 +readonly #robotGroups$$ = new BehaviorSubject([]); +public readonly robotGroups = useObservable( + this.#robotGroups$$.pipe(debounceTime(300)) +); +``` + +### 8.2 实时机器人更新 + +```typescript +public refreshRobot(id: RobotInfo['id'], info: Partial): void { + const pen = this.getPenById(id); + const { rotate: or, robot } = pen ?? {}; + if (!robot?.type) return; + + // 1. 获取当前位置 + const { x: ox, y: oy } = this.getPenRect(pen!); + + // 2. 解析实时数据 + const { x: cx = 37, y: cy = 37, active, angle, path: points } = info; + + // 3. 计算新位置(机器人中心点偏移) + const x = cx - 37; + const y = cy - 37; + const rotate = angle ?? or; + + // 4. 处理路径数据 + const path = points?.map((p) => ({ x: p.x - cx, y: p.y - cy })) ?? + robot.path?.map((p) => ({ x: p.x + ox! - x, y: p.y + oy! - y })); + + // 5. 更新机器人状态 + const o = { ...robot, ...omitBy({ active, path }, isNil) }; + + // 6. 根据激活状态使用不同的更新策略 + if (isNil(active)) { + // 只更新位置和路径 + this.setValue( + { id, x, y, rotate, robot: o, visible: true }, + { render: true, history: false, doEvent: false } + ); + } else { + // 同时更新图片资源 + this.setValue( + { + id, + ...this.#mapRobotImage(robot.type, active), + x, y, rotate, robot: o, visible: true + }, + { render: true, history: false, doEvent: false } + ); + } +} +``` + +## 9. 点位和路线管理 + +### 9.1 点位创建 + +```typescript +public async addPoint(p: Point, type = MapPointType.普通点, id?: string): Promise { + id ||= s8(); + + // 1. 创建点位图形对象 + const pen: MapPen = { + ...p, // 坐标 + ...this.#mapPoint(type), // 尺寸配置 + ...this.#mapPointImage(type), // 图片配置 + id, + name: 'point', + tags: ['point'], + label: `P${id}`, + point: { type }, + }; + + // 2. 调整坐标到中心点 + pen.x! -= pen.width! / 2; + pen.y! -= pen.height! / 2; + + // 3. 添加到画布 + await this.addPen(pen, false, true, true); +} +``` + +### 9.2 路线创建 + +```typescript +public addRoute(p: [MapPen, MapPen], type = MapRouteType.直线, id?: string): void { + const [p1, p2] = p; + if (!p1?.anchors?.length || !p2?.anchors?.length) return; + + // 1. 连接两个点位 + const line = this.connectLine(p1, p2, undefined, undefined, false); + + // 2. 设置ID + id ||= line.id!; + this.changePenId(line.id!, id); + + // 3. 设置路线属性 + const pen: MapPen = { tags: ['route'], route: { type }, lineWidth: 1 }; + this.setValue({ id, ...pen }, { render: false, history: false, doEvent: false }); + + // 4. 更新线条类型 + this.updateLineType(line, type); + + // 5. 选中并渲染 + this.active(id); + this.render(); +} +``` + +## 10. 主题系统集成 + +### 10.1 主题响应 + +```typescript +// 监听主题变化 +watch( + () => sTheme.theme, + (v) => this.#load(v), + { immediate: true }, +); + +#load(theme: string): void { + // 1. 设置 Meta2d 主题 + this.setTheme(theme); + + // 2. 更新编辑器配置 + this.setOptions({ color: get(sTheme.editor, 'color') }); + + // 3. 更新所有点位图片 + this.find('point').forEach((pen) => { + if (!pen.point?.type) return; + if (pen.point.type < 10) return; + this.canvas.updateValue(pen, this.#mapPointImage(pen.point.type)); + }); + + // 4. 更新所有机器人图片 + this.find('robot').forEach((pen) => { + if (!pen.robot?.type) return; + this.canvas.updateValue(pen, this.#mapRobotImage(pen.robot.type, pen.robot.active)); + }); + + // 5. 重新渲染 + this.render(); +} +``` + +## 11. 性能优化策略 + +### 11.1 防抖处理 + +```typescript +// 所有响应式数据都使用防抖 +debounceTime(100); // 100ms 防抖 +debounceTime(300); // 300ms 防抖(机器人组) +``` + +### 11.2 浅层响应式 + +```typescript +// 使用 shallowRef 避免深度响应式 +const editor = shallowRef(); +``` + +### 11.3 并行处理 + +```typescript +// 场景加载时并行处理 +await Promise.all( + areas.map(async (v) => { + await this.addArea(/* ... */); + }), +); +``` + +## 12. 总结 + +### 12.1 画区域的完整流程 + +1. **工具选择**:用户点击工具栏的区域工具,设置 `mode` +2. **鼠标监听**:`mouseBrush` 流监听鼠标拖拽事件 +3. **坐标获取**:获取拖拽的起始点和结束点 +4. **区域创建**:调用 `addArea` 方法创建区域对象 +5. **画布绘制**:`drawArea` 函数在 Canvas 上绘制实际图形 +6. **状态更新**:触发响应式数据更新,通知 Vue 组件 + +### 12.2 场景文件生成区域的完整流程 + +1. **文件解析**:将 JSON 字符串解析为 `StandardScene` 对象 +2. **数据提取**:从 `areas` 数组中提取每个区域的信息 +3. **图形创建**:调用 `addArea` 方法在画布上创建图形对象 +4. **属性设置**:通过 `setValue` 设置业务属性 +5. **绘制渲染**:自定义绘制函数在 Canvas 上渲染图形 + +### 12.3 架构优势 + +1. **分层设计**:业务逻辑与图形引擎分离 +2. **响应式驱动**:状态变化自动更新 UI +3. **事件流处理**:RxJS 提供强大的异步事件处理 +4. **自定义绘制**:完全控制图形的渲染效果 +5. **类型安全**:TypeScript 提供完整的类型检查 + +这个编辑器服务是一个设计精良的复杂系统,通过合理的架构设计实现了强大的场景编辑功能。 diff --git a/自动同步仓库.ps1 b/自动同步仓库.ps1 new file mode 100644 index 0000000..550cf40 --- /dev/null +++ b/自动同步仓库.ps1 @@ -0,0 +1,165 @@ +# Git多仓库同步脚本 (PowerShell) +# 用于同步镜像仓库到你的个人仓库 + +param( + [switch]$UseRebase = $false, + [switch]$DryRun = $false +) + +# 设置控制台编码以正确显示中文 +[Console]::OutputEncoding = [System.Text.Encoding]::UTF8 +$OutputEncoding = [System.Text.Encoding]::UTF8 + +# 设置Git配置以正确处理中文 +$env:LC_ALL = "zh_CN.UTF-8" + +function Write-ColorOutput { + param( + [string]$Message, + [string]$Color = "White" + ) + Write-Host $Message -ForegroundColor $Color +} + +function Check-GitStatus { + $status = git status --porcelain + if ($status) { + Write-ColorOutput "警告: 工作目录不干净,请先提交或储藏你的更改!" "Yellow" + git status + return $false + } + return $true +} + +function Sync-Repositories { + Write-ColorOutput "开始同步镜像仓库..." "Cyan" + + # 检查工作目录状态 + if (-not (Check-GitStatus)) { + if (-not $DryRun) { + return + } + } + + # 获取镜像仓库最新代码 + Write-ColorOutput "正在获取upstream最新代码..." "Green" + if (-not $DryRun) { + git fetch upstream + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "获取upstream代码失败!" "Red" + return + } + } else { + Write-ColorOutput "[预览模式] git fetch upstream" "Gray" + } + + # 检查是否有新的提交 + $upstreamCommits = & git log HEAD..upstream/master --oneline | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::Default.GetBytes($_)) } + if ($upstreamCommits) { + Write-ColorOutput "发现镜像仓库有新的提交:" "Yellow" + $upstreamCommits | ForEach-Object { Write-ColorOutput $_ "Gray" } + + if ($UseRebase) { + Write-ColorOutput "正在使用rebase合并代码..." "Green" + if (-not $DryRun) { + git rebase upstream/master + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Rebase失败!请手动解决冲突后再继续。" "Red" + Write-ColorOutput "解决冲突后请运行: git rebase --continue" "Yellow" + return + } + } else { + Write-ColorOutput "[预览模式] git rebase upstream/master" "Gray" + } + } else { + Write-ColorOutput "正在使用merge合并代码..." "Green" + if (-not $DryRun) { + # 生成自动合并消息 + $timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $mergeMessage = "feat: 同步上游仓库更新 ($timestamp)" + + git merge upstream/master --no-edit -m $mergeMessage + if ($LASTEXITCODE -ne 0) { + Write-ColorOutput "Merge失败!请手动解决冲突后再继续。" "Red" + Write-ColorOutput "解决冲突后请运行: git commit" "Yellow" + return + } + Write-ColorOutput "使用自动合并消息: $mergeMessage" "Gray" + } else { + Write-ColorOutput "[预览模式] git merge upstream/master --no-edit -m '自动合并消息'" "Gray" + } + } + + Write-ColorOutput "同步完成!" "Green" + Write-ColorOutput "" + Write-ColorOutput "下一步操作提示:" "Cyan" + Write-ColorOutput " 请手动推送到你的仓库: git push origin master" "White" + if ($UseRebase) { + Write-ColorOutput " (如果使用了rebase,可能需要: git push origin master --force-with-lease)" "Yellow" + } + + } else { + Write-ColorOutput "镜像仓库没有新的提交,检查本地是否有未推送的修改..." "Blue" + + # 检查是否有本地修改需要推送 + $localCommits = & git log origin/master..HEAD --oneline | ForEach-Object { [System.Text.Encoding]::UTF8.GetString([System.Text.Encoding]::Default.GetBytes($_)) } + if ($localCommits) { + Write-ColorOutput "发现本地有未推送的修改:" "Yellow" + $localCommits | ForEach-Object { Write-ColorOutput $_ "Gray" } + + Write-ColorOutput "" + Write-ColorOutput "下一步操作提示:" "Cyan" + Write-ColorOutput " 请手动推送到你的仓库: git push origin master" "White" + } else { + Write-ColorOutput "没有需要同步的内容。" "Blue" + } + } +} + +function Show-Help { + Write-ColorOutput "Git多仓库同步脚本" "Cyan" + Write-ColorOutput "用法: .\sync-repos.ps1 [选项]" "White" + Write-ColorOutput "" + Write-ColorOutput "选项:" "Yellow" + Write-ColorOutput " -UseRebase 使用rebase代替merge进行合并" "White" + Write-ColorOutput " -DryRun 预览模式,不执行实际的git命令" "White" + Write-ColorOutput " -Help 显示此帮助信息" "White" + Write-ColorOutput "" + Write-ColorOutput "示例:" "Yellow" + Write-ColorOutput " .\sync-repos.ps1 # 使用merge同步" "White" + Write-ColorOutput " .\sync-repos.ps1 -UseRebase # 使用rebase同步" "White" + Write-ColorOutput " .\sync-repos.ps1 -DryRun # 预览模式" "White" + Write-ColorOutput "" + Write-ColorOutput "注意: 脚本不会自动推送到你的仓库,需要手动执行推送命令。" "Yellow" +} + +# 主程序 +if ($args -contains "-Help" -or $args -contains "--help" -or $args -contains "-h") { + Show-Help + return +} + +Write-ColorOutput "Git多仓库同步工具" "Cyan" +Write-ColorOutput "===================" "Cyan" + +# 显示当前配置 +Write-ColorOutput "当前远程仓库配置:" "Blue" +git remote -v + +Write-ColorOutput "" +if ($DryRun) { + Write-ColorOutput "预览模式 - 不会执行实际的git命令" "Yellow" +} +if ($UseRebase) { + Write-ColorOutput "使用rebase模式" "Yellow" +} else { + Write-ColorOutput "使用merge模式" "Yellow" +} +Write-ColorOutput "" + +Sync-Repositories + +# 等待用户按键后再关闭 +Write-ColorOutput "" +Write-ColorOutput "按任意键继续..." "Green" +$null = $Host.UI.RawUI.ReadKey("NoEcho,IncludeKeyDown") \ No newline at end of file