Files
llm-wiki/docs/spark/2026-06-17-static-renderer-coordination-split-design.md
T
2026-07-12 21:26:08 +08:00

173 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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`——以"是否构成交互决策"为判据,实现时按铁律归位。