From 27b611c941e2629681657403a5f609bf7093c05b Mon Sep 17 00:00:00 2001 From: xudan Date: Wed, 3 Sep 2025 17:00:32 +0800 Subject: [PATCH] =?UTF-8?q?feat:=E6=96=B0=E5=A2=9E=E5=BA=93=E4=BD=8D?= =?UTF-8?q?=E5=9C=A8=E9=A1=B5=E9=9D=A2=E7=9A=84=E6=B8=B2=E6=9F=93=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E5=A4=9A=E5=B1=82=E7=BA=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/库位渲染与状态集成说明.md | 131 +++++++++++++++++++ src/services/draw/storage-location-drawer.ts | 96 ++++++++++++++ src/services/editor.service.ts | 6 + src/services/storage-location.service.ts | 8 +- 4 files changed, 238 insertions(+), 3 deletions(-) create mode 100644 docs/库位渲染与状态集成说明.md create mode 100644 src/services/draw/storage-location-drawer.ts diff --git a/docs/库位渲染与状态集成说明.md b/docs/库位渲染与状态集成说明.md new file mode 100644 index 0000000..35311b1 --- /dev/null +++ b/docs/库位渲染与状态集成说明.md @@ -0,0 +1,131 @@ +# 库位渲染与状态集成说明 + +本文档说明当前项目中“库位网格”在 Meta2D 渲染链路中的实现方式、扩展点与接入 WebSocket 实时状态的建议方案,并给出从旧的 DOM 覆盖层组件迁移到画布内绘制的步骤与注意事项。 + +- 相关文件 + - 画布渲染入口:`src/services/editor.service.ts` 中 `drawPoint()` 自定义绘制 + - 库位网格绘制模块:`src/services/draw/storage-location-drawer.ts`(导出 `drawStorageGrid`) + - 场景编辑页:`src/pages/scene-editor.vue` + - WS 服务(示例/参考):`src/services/storage-location.service.ts` + +## 一、渲染概览 + +- 使用 Meta2D 的自定义绘制回调,在 `drawPoint()` 内针对 `MapPointType.动作点` 调用 `drawStorageGrid()`,在“世界坐标”中直接绘制 2x3 栅格。 +- 这样,库位网格与点位同步缩放和平移,无需计算 DOM 偏移与容器滚动,也无需在模板中叠加覆盖层组件。 + +## 二、数据来源与字段约定 + +- 点位对象:`MapPen`(`@api/map` 导出的类型)。 +- 动作点扩展字段: + - `pen.point.associatedStorageLocations: string[]` + - 用于静态展示与数量溢出提示(+N)。 + - 后续若接入 WS 实时状态,建议增加: + - `pen.point.storageStates?: Record` + - key 为库位层名(例如 `layer_name`),value 为状态对象。 + +## 三、绘制模块 `drawStorageGrid` + +路径:`src/services/draw/storage-location-drawer.ts` + +签名: +```ts +export function drawStorageGrid( + ctx: CanvasRenderingContext2D, + pen: MapPen, + opts?: { fontFamily?: string }, +): void +``` + +职责: +- 基于 `pen.calculative.worldRect` 决定栅格的尺寸、间距与摆放位置(点位右上角)。 +- 前 5 格按默认样式绘制;若数量超出 6,则在最后一格显示 `+N`。 +- 采用“世界坐标”绘制,随 Meta2D 画布的缩放/平移自然同步。 + +可调参数(在代码内集中计算): +- 单格尺寸 `cell`:约为点位较短边的 35%,限制在 `[6, 14]`(世界坐标单位)。 +- 间距 `gap`:约为点位较短边的 8%,限制在 `[2, 6]`。 +- 圆角统一通过 `roundedRectPath` 构造路径,避免 `roundRect` 类型兼容问题。 + +## 四、在 `drawPoint()` 中的接入 + +路径:`src/services/editor.service.ts` + +核心片段: +```ts +// …计算文本等 +ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); + +// 库位2x3栅格:动作点在画布上直接绘制(静态数据) +if (type === MapPointType.动作点) { + drawStorageGrid(ctx, pen, { fontFamily }); +} +``` + +注意:需确保 `EditorService.#register()` 已注册 point 的自定义绘制函数(项目中已完成)。 + +## 五、接入 WS 实时状态(建议) + +有两种模式可选: + +- 模式 A:在 `drawStorageGrid()` 增加状态解析回调 + - 签名扩展:`opts.stateResolver?: (penId: string, layerName: string) => { occupied?: boolean; locked?: boolean; }` + - `drawPoint()` 调用时传入解析函数(从全局 Map 或服务读取),按状态填充不同的底色/边框/角标。 + - 优点:不污染 `pen` 数据,职责清晰;适合不同页面传入不同策略。 + +- 模式 B:将状态写回 `pen` 上 + - WS 收到后更新 `pen.point.storageStates[layer_name] = { … }`,然后 `editor.render()` 触发重绘。 + - `drawStorageGrid()` 读取 `pen.point.storageStates` 决定样式。 + - 优点:渲染模块无额外依赖;缺点:需管理好数据生命周期与清理。 + +建议优先使用模式 A,便于解耦与测试。 + +### 与 `StorageLocationService` 的对接 + +`StorageLocationService.handleStorageLocationUpdate()` 已按 `operate_point_id` 与站点名映射聚合库位数据: +- 可在服务层维护 `Map>` 的结构。 +- `drawPoint()` 中调用 `drawStorageGrid(ctx, pen, { stateResolver })`,`stateResolver` 从该 Map 中读取。 +- 状态变化后调用 `editor.render()` 重绘即可生效。 + +## 六、样式与语义(可自定义) + +- 默认颜色: + - 底板:`#00000022`(半透明) + - 单格:`#f5f5f5` + `#999` 边框 + - 溢出格:底 `#e6f4ff`,边框 `#1677ff`,文字 `#1677ff` +- 状态上色(建议): + - occupied=true:填充偏黄/橙(例:`#ffe58f`) + - locked=true:边框高亮(例:`#fa541c`)或角标图标 + - 冲突(同时 occupied & locked):设置优先级或组合样式 + +## 七、性能考量 + +- 绘制在同一 Canvas 渲染管线完成,没有额外 DOM 或布局成本。 +- 仅当 WS 推送或交互(缩放/平移/选择)触发重绘,保持流畅。 +- 如果点位非常密集,可在 `drawStorageGrid` 内做最小尺寸阈值,过小则不绘制或绘制为简化标记。 + +## 八、迁移步骤(从 DOM 覆盖层组件) + +1. 将组件 `storage-location-grid-overlay.vue` 从页面删除(如 `scene-editor.vue`)。 +2. 确认 `drawPoint()` 已导入并调用 `drawStorageGrid()`。 +3. 按需对接 WS:在服务层聚合状态,传入解析回调或写入 `pen`。 +4. 验证缩放/平移/窗口尺寸变化场景,确保网格对齐与可读性。 + +## 九、测试清单 + +- 缩放到 0.5x、1x、2x 时,栅格位置与尺寸是否稳定且可读。 +- 拖动画布及窗口 resize 后,栅格仍紧邻动作点右上角。 +- 当 `associatedStorageLocations.length` <= 6 与 > 6 时展示正确(含 `+N`)。 +- 接入 WS 后: + - occupied/locked 状态能正确上色/标注。 + - 状态切换时无残影,性能稳定。 + +## 十、常见问题 FAQ + +- Q:为何不再使用 DOM 覆盖层组件? + - A:避免坐标换算/偏移/滚动同步等复杂问题,统一交由 Meta2D 世界坐标与渲染管线处理。 + +- Q:如果需要支持点击库位打开菜单? + - A:可在 Meta2D 的事件系统中命中该区域(保留格子范围),或保留一个轻量的浮层仅用于复杂交互(与绘制相互独立)。 + +- Q:如何快速调整大小与颜色? + - A:修改 `storage-location-drawer.ts` 内的 `cell/gap` 与颜色常量;如需主题化,可迁移到 `sTheme`。 diff --git a/src/services/draw/storage-location-drawer.ts b/src/services/draw/storage-location-drawer.ts new file mode 100644 index 0000000..5cba3ec --- /dev/null +++ b/src/services/draw/storage-location-drawer.ts @@ -0,0 +1,96 @@ +import type { MapPen } from '@api/map'; + +function roundedRectPath( + ctx: CanvasRenderingContext2D, + x: number, + y: number, + w: number, + h: number, + r: number, +) { + const rr = Math.max(0, Math.min(r, Math.min(w, h) / 2)); + ctx.moveTo(x + rr, y); + ctx.lineTo(x + w - rr, y); + ctx.arcTo(x + w, y, x + w, y + rr, rr); + ctx.lineTo(x + w, y + h - rr); + ctx.arcTo(x + w, y + h, x + w - rr, y + h, rr); + ctx.lineTo(x + rr, y + h); + ctx.arcTo(x, y + h, x, y + h - rr, rr); + ctx.lineTo(x, y + rr); + ctx.arcTo(x, y, x + rr, y, rr); +} + +/** + * 在点位右上角绘制 2x3 库位栅格(静态,来自 associatedStorageLocations)。 + * 随画布缩放和平移,由 Meta2D 世界坐标系统自然处理。 + */ +export function drawStorageGrid( + ctx: CanvasRenderingContext2D, + pen: MapPen, + opts?: { fontFamily?: string }, +): void { + const { x = 0, y = 0, width: w = 0, height: h = 0 } = pen.calculative?.worldRect ?? {}; + const assoc: string[] = pen.point?.associatedStorageLocations || []; + if (!assoc.length) return; + + const fontFamily = opts?.fontFamily ?? pen.calculative?.fontFamily; + + // 尺寸与间距(世界坐标) + const base = Math.min(w, h); + const cell = Math.max(6, Math.min(14, base * 0.35)); + const gap = Math.max(2, Math.min(6, base * 0.08)); + const gridCols = 3; + const gridRows = 2; + const gridW = gridCols * cell + (gridCols - 1) * gap; + const gridH = gridRows * cell + (gridRows - 1) * gap; + + // 右上角位置 + const outer = gap; + const gx = x + w + outer; + const gy = y - gridH - outer; + + ctx.save(); + // 背景底 + ctx.beginPath(); + roundedRectPath(ctx, gx - gap * 0.5, gy - gap * 0.5, gridW + gap, gridH + gap, Math.min(6, cell * 0.4)); + ctx.fillStyle = '#00000022'; + ctx.fill(); + + // 前 5 个格子 + 最后一格可能显示 +N + const slots = assoc.slice(0, gridCols * gridRows); + slots.forEach((_, i) => { + const r = Math.floor(i / gridCols); + const c = i % gridCols; + const cx = gx + c * (cell + gap); + const cy = gy + r * (cell + gap); + ctx.beginPath(); + roundedRectPath(ctx, cx, cy, cell, cell, Math.min(4, cell * 0.3)); + ctx.fillStyle = '#f5f5f5'; + ctx.fill(); + ctx.strokeStyle = '#999999'; + ctx.stroke(); + }); + + const overflow = Math.max(0, assoc.length - gridCols * gridRows); + if (overflow > 0) { + const i = gridCols * gridRows - 1; + const r = Math.floor(i / gridCols); + const c = i % gridCols; + const cx = gx + c * (cell + gap); + const cy = gy + r * (cell + gap); + ctx.beginPath(); + roundedRectPath(ctx, cx, cy, cell, cell, Math.min(4, cell * 0.3)); + ctx.fillStyle = '#e6f4ff'; + ctx.fill(); + ctx.strokeStyle = '#1677ff'; + ctx.stroke(); + + ctx.fillStyle = '#1677ff'; + ctx.font = `${Math.floor(cell * 0.6)}px/${1} ${fontFamily ?? 'sans-serif'}`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('+' + overflow, cx + cell / 2, cy + cell / 2); + } + + ctx.restore(); +} diff --git a/src/services/editor.service.ts b/src/services/editor.service.ts index 236b739..b5c951b 100644 --- a/src/services/editor.service.ts +++ b/src/services/editor.service.ts @@ -30,6 +30,7 @@ import { BehaviorSubject, debounceTime, filter, map, Subject, switchMap } from ' import { reactive, watch } from 'vue'; import { AreaOperationService } from './area-operation.service'; +import { drawStorageGrid } from './draw/storage-location-drawer'; import { LayerManagerService } from './layer-manager.service'; /** @@ -1543,6 +1544,11 @@ function drawPoint(ctx: CanvasRenderingContext2D, pen: MapPen): void { ctx.textAlign = 'center'; ctx.textBaseline = 'top'; ctx.fillText(label, x + w / 2, y - fontSize * lineHeight); + + // 库位2x3栅格:动作点在画布上直接绘制(静态数据) + if (type === MapPointType.动作点) { + drawStorageGrid(ctx, pen, { fontFamily }); + } ctx.restore(); } /** diff --git a/src/services/storage-location.service.ts b/src/services/storage-location.service.ts index c3e3e36..9d282aa 100644 --- a/src/services/storage-location.service.ts +++ b/src/services/storage-location.service.ts @@ -116,14 +116,16 @@ export class StorageLocationService { */ private handleStorageLocationUpdate(message: StorageLocationMessage) { if (message.type === 'storage_location_update') { - // 建立站点名称到画布点ID的映射 + // 优先使用后端提供的 operate_point_id 直接映射到画布点ID; + // 若无该字段或为空,再回退通过站点名映射到点标签。 const stationToPointIdMap = this.buildStationToPointIdMap(); // 按画布点ID组织库位数据 const locationsByPointId = new Map(); message.data.storage_locations.forEach((location) => { - const stationName = location.station_name; // 如 "AP9" - const pointId = stationToPointIdMap.get(stationName); // 获取对应的画布点ID,如 "3351" + const byOperateId = location.operate_point_id; // 直接对应动作点ID + const byStationName = stationToPointIdMap.get(location.station_name); + const pointId = byOperateId || byStationName; if (pointId) { if (!locationsByPointId.has(pointId)) {