173 lines
17 KiB
Markdown
173 lines
17 KiB
Markdown
# static-renderer 协调逻辑拆分设计
|
||
|
||
日期:2026-06-17
|
||
状态:待用户复核
|
||
分支:`spark/static-renderer-coordination-split`(基于 main `f8a834d`)
|
||
路线:B —— 抽出 Controller,并把渲染侧按职责拆到位
|
||
|
||
## 目的
|
||
|
||
上一轮"图谱六层架构"重构(已合并进 main)把图谱的**决策权**收敛到了正确的模块:手势归 `gestures.ts`、坐标归 `viewport.ts`/`geometry.ts`、命中归 `spatial-index.ts`/`hit-testing.ts`、状态归 `state.ts`,画图零件也拆成了 `nodes.ts`/`edges.ts`/`community-washes.ts`/`minimap.ts` 等独立模块。
|
||
|
||
但有一块没收尾:`packages/graph-engine/src/render/static-renderer.ts` 仍有 **2481 行**,同时承担了**四种不同职责**——组装、调度指挥、画图编排、对外接口。它名叫"renderer"(渲染器),实际却是事实上的"总指挥"。这是六层设计原本想达到、但没做到的一块(设计原文要求"GraphRenderer 只负责画图")。
|
||
|
||
本设计的目标:把这块"既指挥又画图"的混合体,按职责拆成边界清楚的模块,让"做决定的"和"画图的"彻底分开,且**以后新增交互时不会再把逻辑塞回画图层**。
|
||
|
||
这不是性能优化。当前图谱性能良好(见下方性能基线)。这是纯粹的代码结构整理,**不改变任何用户可见行为**。
|
||
|
||
## 现状(事实,带证据)
|
||
|
||
`static-renderer.ts`(2481 行)内部混着四类东西:
|
||
|
||
1. **组装**:`createStaticGraphRenderer()` 创建 root 元素、gestures 控制器、runtime state、hit resolver、pin state、simulation、resize observer,并把它们接线。
|
||
2. **调度指挥**:`applyGestureIntents()` 把手势意图派发给 `handleNodeClick` / `handleNodeDragStart` / `handleNodeDragMove` / `handleNodeDragEnd` / `handleNodeDragCancel` / `handleBlankClick`;以及语义命令 `selectCommunity` / `focusCommunity` / `resetViewState` / `retreatFocusedView` / `openSearch` / `applySearchQuery` / `closeSearch` / `clearInteractionState`;还有键盘意图路由 `handleDocumentKeydown`。
|
||
3. **画图编排**:`render()`(重建渲染模型→重绘→挂控件→提交相机→画 overlay→重启模拟)、`paint()`(把画图零件拼成 DOM 树)、`mountSearchControl` / `mountGraphToolbar` / `mountCommunityLegend`、`commitViewport` / `updateMinimapViewport` / `updateEffectiveDensity`、diff 动画 `markDiffElements` / `settleDiffElements` / `animateDiff`。
|
||
4. **hover / 阅读器 / 选择面板**:`scheduleHoverPreview` / `showEdgeHoverPreview` / `clearHoverPreview` / `renderHoverPreview` / `positionHoverPreview` / `positionEdgeHoverPreview` / `renderReader` / `renderSelectionPanel`。
|
||
5. **对外接口**:返回的对象(`render` / `applyDiff` / `setData` / `setTheme` / `setPins` / `focusNode` / `focusCommunity` / `resetView` / `select` / `clearSelection` / `clearInteraction` / `resetLayout` / `destroy`),由 `facade.ts` 包装成公开 API。
|
||
|
||
三个必须正视的结构现实(自审时对回代码确认,决定了拆分怎么做才不返工):
|
||
|
||
- **调度函数会直接操作 DOM 元素**:如 `dom.nodeElements.get(id)?.focus()`、`.classList.add("is-dragging")`、社区 hover 高亮遍历 `dom.nodeElements`。这些是"交互附着物",不是画图——所以铁律必须区分"画图"与"附着",而不是简单说"调度不碰 DOM"。
|
||
- **`render()` 是三合一**:它同时"把传入的改动写进 state(`setPins`/`setFocus`/`setSelection`/`setPositions`)+ 重建渲染模型(`buildRenderableGraph`/`new PinState`/`hitTargetResolver.refresh`)+ 绘制(`paint`/`mount*`)"。拆分时要把"写状态"和"重建并画"分开,不能整块塞给 render-pipeline 还说它"只画图"。
|
||
- **存在共享可变状态**:`graph`、`pinState`、`dom`、`simulation`(以及 `data`/`theme`/`typeFilters`)现在是闭包变量,被调度和画图两边读写、还会被 `render()` 重新赋值。拆成多文件后,这些必须变成一个显式的"共享渲染上下文"对象(见下节),否则模块间无法干净协作。
|
||
|
||
基线(实测):单元测试 265 个全部通过;密集图(200 节点 / 231 边)连续缩放稳定约 50.5 fps。
|
||
|
||
## 核心原则(这是"不会再返工"的关键)
|
||
|
||
1. **铁律(精确版,已对回真实代码)**:
|
||
- Controller **不构建、不绘制图**:不调用 `paint`/`mount*`、不创建节点/边/色块 DOM、不计算渲染模型、不做布局。
|
||
- Controller **允许**在**已经画好的元素**上切换"交互附着物":给节点加/去 `is-dragging`、设置 `focus`、hover 高亮。这类是"对交互的附着",不是"画图"。(真实代码里这些操作本就存在,硬禁会一上手就违规——见"现状"第 2 点。)
|
||
- Render-pipeline / Overlays-presenter **只重建模型并绘制**:不判断手势意义、不决定 selection/focus/pin 等语义。
|
||
2. **停手线**:模块就定下面这几个,每个对应一个明确职责。拆到这个粒度就**停手**,不再为了凑文件数继续切碎。这是成熟标准——按职责拆到位,既不留 god 文件,也不拆成 confetti 碎片。
|
||
3. **纯搬家**:本次只移动和重组代码,不改逻辑、不改行为。任何"顺手优化"都不在范围内。
|
||
4. **共享状态集中**:`graph`/`pinState`/`dom`/`simulation` 等"连接组织"集中放进一个"共享渲染上下文",由组装根持有,传给各模块;不让任意模块各自藏一份平行状态(详见下节)。
|
||
|
||
## 目标模块结构
|
||
|
||
`gestures.ts` / `viewport.ts` / `state.ts` / `spatial-index.ts` / `hit-testing.ts` 以及所有画图零件模块(`nodes.ts` / `edges.ts` / `community-washes.ts` / `minimap.ts` / `controls.ts` / `offline-reader.ts` / `hover-card.ts` / `toolbar.ts` / `legend.ts` / `search.ts` / `preview.ts`)**保持不动**。本次只新增/重组下面的协调与编排层:
|
||
|
||
| 模块(文件) | 职责(一句话) | 拥有的代表函数 | 允许依赖 | 禁止 |
|
||
|---|---|---|---|---|
|
||
| `controller.ts`(新) | 总指挥:把手势意图和 API 命令翻译成"该改什么状态/模拟/相机,然后请求重画" | `applyGestureIntents`、`handleNode*`、`handleBlankClick`、语义命令(select/focus/reset/search/clear)、键盘意图路由、node-drag 计算 helper | `state`、`viewport`、`gestures`、`simulation-bridge`、`node-drag-lifecycle`、`hit-testing`、`keyboard` | 创建/绘制图 DOM、调用 `paint`/`mount*`、计算渲染模型(**允许**在已画元素上切换 `is-dragging`/`focus` 等交互附着物) |
|
||
| `render-pipeline.ts`(新) | 画图编排:照当前状态把图重绘出来 | `render`、`paint`、`mount*`、`commitViewport`、`update*`、diff 动画、`restartSimulation`/`applyMotionFrame` 等绘制编排 | 所有画图零件模块、`state`(只读)、`viewport`(投影) | 判断手势意义;写入交互决策 |
|
||
| `overlays-presenter.ts`(新) | hover 卡片 / 边预览 / 阅读器 / 选择面板的"贴位置 + 显示" | `scheduleHoverPreview`、`showEdgeHoverPreview`、`clearHoverPreview`、`renderHoverPreview`、`position*Preview`、`renderReader`、`renderSelectionPanel` | `overlays.ts`(锚点)、`preview.ts`、`hover-card.ts`、`viewport`、`state`(只读) | 判断手势意义;决定状态 |
|
||
| `graph-renderer-root.ts`(由 `static-renderer.ts` 改名瘦身) | 组装根:创建上述模块、接线,返回供 facade 包装的引擎对象 | `createGraphRenderer()`(原 `createStaticGraphRenderer`)、`destroy`、对外方法的委派 | 上述所有协调/编排模块 | 自己实现调度或画图细节(只做组装与委派) |
|
||
|
||
说明:`controller.ts` 同时拥有"手势触发的处理"和"API 命令(如 `focusCommunity`/`resetView`)",因为它们是同一职责——"对一个动作做出决定"。把它们合在一个模块,正是为了守住"停手线",不再过度拆分。
|
||
|
||
## 共享渲染上下文(本设计最关键、最容易做错的一块)
|
||
|
||
调度和画图不是"井水不犯河水",它们之间有一坨**连接组织**:`graph`(当前渲染模型)、`pinState`(固定状态)、`dom`(已画出的元素引用)、`simulation`(力导模拟)、以及 `data`/`theme`/`typeFilters` 等当前输入。现在它们是 `static-renderer.ts` 里的闭包变量,谁都能直接读写。
|
||
|
||
拆成多文件后,必须把它们收进**一个显式的共享上下文对象**(暂名 `GraphRenderContext`),规则:
|
||
|
||
- **由组装根(`graph-renderer-root.ts`)创建并持有**这个上下文,传给 controller、render-pipeline、overlays-presenter。
|
||
- 上下文里"会被重建/重新赋值"的字段(`graph`、`pinState`、`dom`)通过上下文对象的方法或属性更新,**只有 render-pipeline 在重建时写它们**;controller 和 presenter 只读。
|
||
- `runtimeState`(已有的 state 模块)仍是 selection/focus/hover/pins/viewport 的唯一权威;上下文不复制这些,只持有"渲染产物"(graph/dom)和"运行期协作对象"(pinState/simulation)。
|
||
- `render()` 拆成两步:**(a) 应用传入改动到 state**(归 controller / 根的命令侧)+ **(b) 重建模型并绘制**(归 render-pipeline,写回上下文的 graph/dom/pinState)。
|
||
|
||
这一节是整份设计的重点。若不先定清楚共享上下文,"按职责拆"会在落地时卡住或被迫返工——这正是要避免的。具体字段和方法签名在实现计划阶段定稿。
|
||
|
||
## 数据流
|
||
|
||
拖动节点:
|
||
|
||
```
|
||
你拖节点
|
||
-> gestures 翻译成"拖拽意图"
|
||
-> controller 决定:更新该节点位置、固定它、改 state、通知 simulation
|
||
-> controller 请求重画
|
||
-> render-pipeline 照新状态把节点画到新位置
|
||
-> overlays-presenter 把 hover 卡片贴回正确锚点
|
||
```
|
||
|
||
API 命令(如宿主调用 `focusCommunity`):
|
||
|
||
```
|
||
facade.focusCommunity(id)
|
||
-> graph-renderer-root 委派给 controller
|
||
-> controller 改 focus state、请求重画
|
||
-> render-pipeline 重绘聚焦视图
|
||
```
|
||
|
||
每一层只干自己那段;出问题能立刻定位是"指挥错了"还是"画错了"。
|
||
|
||
## 命名决策
|
||
|
||
- 文件 `render/static-renderer.ts` → `render/graph-renderer-root.ts`(它以后只管组装,名字应说明这一点)。
|
||
- 对外函数 `createStaticGraphRenderer` → `createGraphRenderer`("static" 是早期遗留词,已无意义)。
|
||
- 影响面(已精确核对):`facade.ts`(import + 调用)、`render/index.ts`(re-export `createStaticGraphRenderer` 与类型 `StaticGraphRenderer`,39/40 行)、`architecture.ts`(entrypoints 写死路径,43 行)、`renderer-boundary.test.ts`(按路径字符串读文件,117/124 行),外加文件自身。(注:`sim/index.ts`/`sim/pins.ts` 并不引用它,之前的宽松 grep 误命中,已剔除。)
|
||
- 注意:其中两处**不是"测试自动接住",而是必须手动改**——`renderer-boundary.test.ts` 按路径字符串读 `render/static-renderer.ts`,`architecture.ts` 的 `entrypoints` 也写死了该路径。改名必须同步更新这两处,否则测试会因找不到文件而失败。
|
||
- `architecture.ts` 中 `renderer` 层的 `entrypoints` 需相应更新;新增 `controller` 层条目(见下)。
|
||
|
||
`architecture.ts` 调整:当前六层 owner map 把 `static-renderer.ts` 归在 `renderer` 层。重组后:
|
||
- `renderer` 层 entrypoints 指向 `render-pipeline.ts`、`overlays-presenter.ts` 及画图零件;不再把组装/调度算作 renderer。
|
||
- 视实现情况,将"调度"明确为 `gestures` 层的下游协调,或在 owner map 中体现 `controller.ts` 的归属(实现时确定:要么并入 `gestures` 层描述,要么新增一层条目)。原则是 owner map 必须如实反映"谁做决定、谁画图"。
|
||
|
||
## 迁移计划(一次搬一块,每块跑全套测试才提交)
|
||
|
||
沿用上一条分支的纪律:每个工作单元完成并验证通过才提交,红了不提交,不自动 push/merge/amend。
|
||
|
||
- **Phase 0 基线**:跑全套单元测试 + 4 套浏览器回归 + 帧率测试,记录"改之前是绿的、约 50fps"。不写功能代码。
|
||
- **Phase 1 建立共享渲染上下文 + 抽 Controller**:先把 `graph`/`pinState`/`dom`/`simulation` 等闭包变量收进显式的 `GraphRenderContext`,由根持有;再把调度/命令/键盘路由搬进 `controller.ts`(通过上下文只读这些、用 state 模块改语义状态、用现有 `dom` 引用切换交互附着物)。跑测试。
|
||
- **Phase 2 抽 Render-pipeline**:把 `render()` 拆成"应用改动到 state"与"重建模型并绘制"两步,后者连同 `paint()`/挂控件/相机提交/diff 动画搬进 `render-pipeline.ts`,并由它写回上下文的 `graph`/`dom`/`pinState`。跑测试。
|
||
- **Phase 3 抽 Overlays-presenter**:把 hover/边预览/阅读器/选择面板搬进 `overlays-presenter.ts`。跑测试。
|
||
- **Phase 4 收尾与改名**:`static-renderer.ts` 缩成薄组装根并改名为 `graph-renderer-root.ts`;函数改名 `createGraphRenderer`;更新引用与 `architecture.ts`;补充清晰注释。跑测试。
|
||
- **Phase 5 边界测试与总验收**:新增/扩展边界测试锁死铁律;跑完整验收命令集 + 帧率对比基线。
|
||
|
||
## 测试与安全策略
|
||
|
||
- **安全网**:现有 265 个单元测试 + 4 套浏览器回归(workbench / offline / community-wash / stage-4.5 perf)每个 phase 都必须全绿。因为是纯搬家,"行为不变"由这些既有测试保证。
|
||
- **性能门槛**:收尾重跑 stage-4.5 密集图帧率测试,fps 必须 ≥ 基线(约 50fps,允许 10% 波动);低于则不通过。
|
||
- **新增边界测试**(锁死铁律,防止以后回潮):
|
||
- `controller.ts` 不引用画图零件模块(`nodes`/`edges`/`community-washes`/`minimap` 等的 `create*`)、不调用 `paint`/`mount*`、不计算渲染模型;但允许通过上下文的 `dom` 切换 `is-dragging`/`focus` 等交互附着物(这是被明确允许的,不算违规)。
|
||
- `render-pipeline.ts` / `overlays-presenter.ts` 不调用 gesture 分类、不写 selection/focus/pin 等语义决策。
|
||
- 尽量包含一个运行时探针(仿照现有 `renderer-boundary.test.ts`),不只做源码正则扫描。
|
||
- **进度记录从简**:上一轮"每个代码提交都配一个进度记账提交"过重。本次按 phase 记一次即可,不要求逐提交记账。
|
||
|
||
## 性能基线(实测)
|
||
|
||
| 场景 | 数据 | 结果 |
|
||
|---|---|---|
|
||
| 密集图连续缩放 | 200 节点 / 231 边,桌面 1440×960,采样 3 秒 | 50.5 fps(空闲基线 12.6 仅因无动画时不刷帧,属正常)|
|
||
|
||
来源:`tests/graph-browser-stage-4-5.regression-1.sh --target offline`。
|
||
|
||
## 不在范围内
|
||
|
||
- 不改任何用户可见行为。
|
||
- 不做性能优化(当前不卡)。
|
||
- 不动画图零件模块、`gestures.ts`、`viewport.ts`、`state.ts`、`spatial-index.ts`。
|
||
- 不动 `facade.ts` 的公开 API 形状(只更新它对内部函数的引用名)。
|
||
- 不引入新依赖、新测试框架。
|
||
- 不顺手重构无关代码。
|
||
- 不切 WebGL / 不改图谱数据结构 / 不改知识库 markdown。
|
||
- 不为了凑文件数把模块继续拆碎(守"停手线")。
|
||
|
||
## 风险与缓解
|
||
|
||
| 风险 | 缓解 |
|
||
|---|---|
|
||
| 搬家时漏接一根线导致行为变化 | 一次只搬一块,每块跑全套测试;红了不提交 |
|
||
| 画图搬错导致视觉/帧率退化 | stage-4.5 帧率门槛 + 浏览器回归把关 |
|
||
| 改名牵动多处引用 | 影响面已知(约 6 处);其中 2 处需手动改(boundary test 的路径字符串 + `architecture.ts` 的 entrypoints),其余由编译/测试兜底;改名集中在 Phase 4 一次做完 |
|
||
| 以后又把调度塞回画图层 | 新增边界测试 + owner map 如实更新 |
|
||
| 模块边界划得不够干净(仍互相伸手) | 铁律 + 边界测试强制"只读状态 / 请求重画"的交互方式 |
|
||
|
||
## 验收标准
|
||
|
||
1. `static-renderer.ts` 已改名为 `graph-renderer-root.ts`,且只剩组装与委派,不含调度决策或画图编排细节。
|
||
2. `controller.ts` / `render-pipeline.ts` / `overlays-presenter.ts` 三个模块各自职责单一、边界清楚。
|
||
3. 265 个单元测试 + 4 套浏览器回归全绿。
|
||
4. 密集图帧率 ≥ 基线(约 50fps)。
|
||
5. 新边界测试通过,证明 Controller 不画图、Render-pipeline/Overlays 不做决定。
|
||
6. `architecture.ts` owner map 如实反映新结构(含路径/entrypoints 更新)。
|
||
7. 共享渲染上下文 `GraphRenderContext` 已建立:`graph`/`pinState`/`dom` 只由 render-pipeline 写,controller/presenter 只读;`render()` 已拆成"应用改动到 state"与"重建并绘制"两步。
|
||
8. 全程行为零变化(由既有测试保证)。
|
||
|
||
## 开放问题(实现时确定,不阻塞本设计)
|
||
|
||
1. `controller.ts` 在 `architecture.ts` 里是并入 `gestures` 层描述,还是新增独立层条目。
|
||
2. 语义命令(select/focus/reset/search)全部归 `controller.ts`,还是其中纯 UI 面板开关(如 toolbar 折叠)留在 `render-pipeline.ts`——以"是否构成交互决策"为判据,实现时按铁律归位。
|