Files
llm-wiki/docs/spark/2026-06-25-sigma-overlay-refactor-perf-design.md
T
2026-07-12 21:26:08 +08:00

10 KiB
Raw Blame History

Sigma 覆盖层重构 + 性能改造 设计文档

  • 日期:2026-06-25
  • 分支:feat/sigma-overlay-refactor-perf
  • 关联 issue#64 拆分 sigma-global-renderer.ts、#63 第 2 条(DOM 重绘重建节点)
  • 范围:本 spec 覆盖 Phase 1#64+ Phase 2#63.2)两阶段。#63 第 1 条(云层签名全量遍历)与 #65(无障碍)不在本 spec 范围内,后续单独处理。

1. 背景与动机

全局视角的知识图谱用 Sigma 在 canvas 上画点和边,再在上面盖一层 DOM/SVG 覆盖物(社区云团、节点命中框、社区标签)。这层覆盖物的全部逻辑集中在 packages/graph-engine/src/render/sigma-global-renderer.ts,当前 1386 行,存在两个已确认的问题:

  1. 单文件过大(#64:云层几何、SVG 渲染、凸包计算、坐标变换都堆在一个文件里。仓库已把 wash 逻辑拆到 community-wash.ts / community-washes.ts,本文件应顺势拆分。
  2. 每帧重建 DOM#63 第 2 条)renderSigmaOverlays() 绑在 Sigma 的 afterRender 事件上 sigma-global-renderer.ts:426),平移/缩放的每一帧都先 overlayRoot.replaceChildren() 清空 :623),再全量重建所有节点命中框、云层 SVG,并重新绑定 click / pointerdown / mousedown / dragstart 监听(:621-715)。但相机移动时节点集合并未改变,只有屏幕坐标变了——这是典型的 DOM 抖动热路径。

注:PR #60 的 1000 节点回归已通过,当前规模不卡。本改造目标是降低维护成本、消除架构坏味道,为更大规模做准备。

设计原则

  • 两个 Phase 相互独立、可分别合并。Phase 1 是纯搬迁(零行为变化),Phase 2 才动行为。
  • 先拆后优化:在 1000+ 行单文件里直接改性能热路径风险叠加;拆完后 Phase 2 改动面更小、更好测。
  • 依赖单向:sigma-coordinatescommunity-cloud-geometrysigma-overlay-svg ← 主文件(编排)。
  • 不改对外接口(SigmaGlobalRenderer),workbench 侧零感知。
  • 遵循 YAGNI:不做通用 diff 框架、不顺手重构无关代码。

2. Phase 1#64):按职责拆 3 个 helper

sigma-global-renderer.ts 抽出三组纯函数 / 无状态工厂。主文件只保留编排核心: createSigmaGlobalRenderer、生命周期、拖拽会话、事件接线、Graphology 属性映射、hit projector、edge style。

新文件 搬入内容 性质
render/sigma-coordinates.ts sigmaWorldPointToScreenPointsigmaScreenPointToWorldPointoverlayPointerScreenPoint 坐标变换
render/community-cloud-geometry.ts SigmaCommunityCloud / SigmaCommunityCloudBasis 接口、sigmaCommunityCloudBasisById + ...WithReuse + ...WithNodePointsigmaCommunityCloudSignaturesigmaProjectedCloudHullPointsclampPointToWorldEllipseclampPointToScreenEllipsesigmaCommunityCloudconvexHull2d 几何 / 凸包,多为纯函数
render/sigma-overlay-svg.ts SIGMA_OVERLAY_SVG_NSnextSigmaCloudFilterSequencesigmaSharedCloudFilterDefsigmaCloudSvg,及 overlay 元素工厂 sigmaOverlayButton / sigmaOverlayPassiveElement / applyOverlayBox / createSigmaOverlayRoot DOM / SVG 构造

依赖说明:community-cloud-geometry 中的投影函数(sigmaProjectedCloudHullPointssigmaCommunityCloud 依赖坐标变换,故 import sigma-coordinates,单向依赖。

预计主文件从 1386 行降到约 1000 行。

Phase 1 验收

  • 行为零变化。
  • 现有 8 个 graph-engine 测试全绿(npm test),npm run typecheck 通过。
  • 几何纯函数补独立单测(凸包、签名、clamp、坐标往返)——这些此前埋在主文件内未单独测。

这些函数当前均为模块私有、未对外导出,搬到新 helper 后由新 helper 各自 export(供单测直接 import),主文件再 import 回来使用。公开 APISigmaGlobalRenderer 等)与 src/index.ts barrel 不变,workbench 侧零感知。模块级计数器 sigmaCloudFilterSequence 随 SVG 函数整组搬走, 仍是同一个计数器,行为不变。


3. Phase 2#63.2):拆"结构更新"与"位置更新"

把唯一的 renderSigmaOverlays()(每帧 replaceChildren 全量重建)拆成两条路径。

rebuildSigmaOverlays() — 仅数据 / 选中变化时调用

  • 按 id 做 diff:复用已存在元素,新增缺失的、移除多余的;维护三张 Map<id, element>(节点命中框、社区云层 region、社区标签各一张)。
  • 监听器(click / pointerdown / mousedown / dragstart只在元素创建时绑一次
  • 处理由数据决定的状态:selected / dim / searchHit / pinned、label 文本等。

覆盖层只渲染这 3 类 DOM 元素。aggregation 容器画在 Sigma canvas 上(走 Graphology 属性),不属于本覆盖层,本改造不涉及。

repositionSigmaOverlays() — 绑 afterRender / resize / 拖拽移动,每帧调用

  • 遍历已存在元素,只更新屏幕坐标(applyOverlayBox)与云层 SVG 的 points 属性。
  • 绝不 replaceChildren / createElement / addEventListener

调用点改造

位置 现状 改为
初始渲染(:336 renderSigmaOverlays() rebuildSigmaOverlays()
数据更新(:382 renderSigmaOverlays() rebuildSigmaOverlays()
afterRender 相机移动 renderSigmaOverlays() repositionSigmaOverlays()
resize raf renderSigmaOverlays() repositionSigmaOverlays()
拖拽提交/取消(applyNodeDragPoint 终态) renderSigmaOverlays() rebuildSigmaOverlays()

选中变化走数据更新路径(触发 rebuild),因此 dim / selected 不进每帧热路径。

实现修正(TDD 发现):原以为"拖拽 → reposition"。实际上拖拽过程中的每帧位置更新由 sigma.refresh()afterRender → reposition 负责;而 applyNodeDragPoint 里那处直接调用是 拖拽提交/取消的终态,此时 pin 状态与永久坐标已变(数据变化),必须 rebuild 才能刷新 dataset.pinned 等属性。

状态归属与不变量(防止双路径打架)

覆盖层完全自封闭:生产代码里只有本文件操作 overlayRoot 与几何/SVG 函数,无第二处 监听 afterRender。因此冲突风险只在文件内部的 rebuild / reposition 之间,必须守住以下不变量:

  • rebuild 是元素生命周期的唯一权威:只有它能增删元素、维护三张 Map<id, element>、绑监听。
  • reposition 对结构只读、且幂等:只改已存在元素的位置/box 与云 hull 投影;遇到表里有、 但当前 adapterData 已无的 id,安全跳过(不报错、不创建)。
  • reposition 每帧从 cloudBasisByCommunityId 重新投影云 hull:拖拽中 basis 冻结、松手才重算, reposition 必须每次重投影才能让云层跟手(与现有行为一致)。
  • update() 的执行顺序安全前提:现状 sigma.refresh():381)→ 真实 Sigma 触发 afterRender → reposition早于 紧随其后的 rebuild:382)。因 reposition 满足"只读+容忍不同步+幂等" 这次提前的 reposition 至多对旧元素做一次无害定位,rebuild 随后纠正为准。
  • afterRendercameraUpdated 都绑同一处理器:425-426),reposition 一帧可能跑 2 次; 因幂等无害,暂不做 rAF 去重(YAGNI,后续按需)。

关键风险点

云层每帧的 hull 投影点会变,所以 reposition 必须更新 polygon 的 points 属性和容器 box, 同时保留同一个 shape 元素及其 click 监听。这是整个改造里最需要小心的一处,测试需专门覆盖。


4. 错误处理与边界

  • 两条路径都保留 destroyed 守卫。
  • 销毁时清空三张 Map<id, element>,避免悬挂引用。
  • diff 按 id 增删;id 不变但内容变(如 label 文本)在 rebuild 内更新属性。
  • 不改对外接口 SigmaGlobalRenderer

5. 测试方案

Phase 1

  • 现有回归全绿 + 几何纯函数新单测(凸包、签名、clamp、坐标往返)。

Phase 2(验收标准:可量化性能基线)

test/sigma-global-renderer.test.ts 复用现有 fakeContainer() / fakeRuntime() / sigma.emit() 基础设施(fake 元素内部已用 Map 记录子节点与监听,无需引入 jsdom):

  1. 量化基线断言(核心):渲染后记录元素引用与 createElement / addEventListener 调用计数 → 多次 sigma.emit("afterRender") → 断言元素实例不变、createElement 计数不增、监听计数不增、 replaceChildren 在 reposition 路径未被调用
  2. 行为保持:点击云层 → 社区选中、点击节点 → 节点选中、拖拽仍生效(沿用现有 emit 用例)。
  3. 结构 diff:数据更新增 / 删节点后,元素表正确增删。
  4. 需更新的现有断言:现有 resize 测试(test/sigma-global-renderer.test.ts:574)当前断言 resize 后覆盖层元素被新建assert.notEqual)。Phase 2 后元素改为复用,这条须改成 assert.equal。这是预期内的行为变更,不是回归——它恰好验证了"reposition 不再重建元素"。
  5. 第三层手动回归:在 codex 跑 1000 节点平移 / 缩放,主观确认不卡 + 基线测试通过 (按 CLAUDE.md 推送前测试规则)。

6. 分 Phase 推进计划

  • Phase 0(本次 spark:写本 spec,分支 feat/sigma-overlay-refactor-perf
  • Phase 1:拆 3 个 helper → 跑回归 → 分步 commit → 可单独开 PR 合入(纯重构、低风险)。
  • Phase 2:基于 Phase 1 改双路径 + 量化基线测试 → codex 回归 → commit → PR。
  • 文档:每个含功能改动的 Phase,按 CLAUDE.md 更新 CHANGELOG / README / 版本号。

7. 验收清单(汇总)

  • Phase 1:主文件显著瘦身、行为零变化、现有测试与 typecheck 全绿、几何函数有独立单测。
  • Phase 2afterRender 路径不再销毁 / 重建 DOM、不重绑监听(量化测试断言通过)。
  • Phase 2:点选社区 / 点选节点 / 拖拽行为与改造前一致。
  • Phase 21000 节点 codex 手动回归平移缩放不卡。
  • 对外接口 SigmaGlobalRenderer 无变化,workbench 无需改动。