Skip to content

fix(visualization): 保证 A -> B 箭头以可见法向终段垂直接入目标边框 #376

Description

@HansBug

已合并完成(2026-07-15)

本区块是 issue #376 的最终状态,覆盖下方“尚未进入 main”的历史说明;历史复现、方案取舍和逐项验收记录保留用于审计。

  • 状态:已由 PR #377 合并到 main
  • merge commit:05c41569ba3e477252f5d9b5cd1d401a6b3f7f65;source HEAD:10642556972ac0a987f3f141c61dcf6cb1711bf2
  • 生产几何门禁:10 个 fixture x TB/LR = 20 个布局;baseline failures 15,fixed failures 0,minimum terminal 18px,crossing regressions 0
  • 真实 VSCode-like 右侧编辑器组:50% pane、4 个窗口尺寸、160 张完整 shell PNG;80 次 drawer resize、80 次真实 drag;平均面积变化 10.6956804842%,最大 23.0576441103%,无新增 crossing/重叠/边穿节点。
  • 本地回归:Python SKIP_SLOW_TESTS=1 make unittest = 46279 passed, 747 skipped, 67 deselected;jsfcstm npm test = 697 passing;ELK targeted = 12 passing;VSCode production compile、make test_boundary_checkmake rst_autogit diff --check 均通过。
  • 证据:公开 Gist,含 report.json、验证器、生成脚本、hash、PNG 和最终报告。
  • 处理决定:问题已完成,下面发布关闭评论并关闭 issue。无关的 mini-racer issue 嵌入 jsfcstm 可视化 (SVG/PNG):mini-racer + WASM 栅格化 #89 不在本次范围内,继续保持 open。

问题摘要

VSCode ELK 预览中的问题不只涉及 self-loop。普通转换 A -> B 也可能在箭头端出现两类相互关联的缺陷:

  1. 终段太短,箭头几乎贴在目标状态 B 的边框转弯,方向难以辨认;
  2. 历史 smoother 为消除短终段而删除/移动折点时,可能把 --> B 的最后一段旋转成沿 B 边框平行行走,箭头落在边框上而不是沿外法线垂直接入。

这个 issue 记录完整前因后果、可重复的几何定义、历史回归复现、已经实测通过的修复方案和合入验收标准。

当前状态说明:下面的修复已在本地工作区完成并通过完整验证,但尚未进入 main。本 issue 是后续 PR 的问题与验收依据,不表示远端主线已经修复。

决策摘要

  • 修复位置:ELK input graph,而不是继续增强 post-layout smoother;
  • 唯一新增布局项:每个嵌套 composite 设置 elk.layered.spacing.edgeNodeBetweenLayers = 18
  • 同一 nested layout 还显式设置 elk.layered.spacing.edgeEdgeBetweenLayers = 32,防止平行 edge lane 在 INCLUDE_CHILDREN 下回退到默认值;
  • 历史候选:20px 曾用于早期方案矩阵和 12 组视觉副作用对照,保留在 Gist 作为历史证据,不是最终 PR 参数;
  • 保持不变:root canvas 的 52px、self-loop 的 28px、smoother 的 18px 安全阈值;
  • 明确不采用:FIXED_SIDE ports、全量复制 root spacing、删除/旋转 endpoint 邻近折点;
  • 必须由测试证明:普通 A -> B 箭头从 B 外部沿实际边框法线接入,终段至少 18px,TB/LR 都成立。
  • 完整复现资产公开 Gist:方案矩阵、扫描器、视觉生成器、全量 JSON 和补丁

主线基线确认

本次结论只针对主线,不使用 dev/damnx

  • 检查的主线基线是 origin/main@971687ca5649cd01bf00239179e38ffda8b5e838v0.6.0);
  • 当前工作树虽然位于另一个开发分支,但在开始修复前,
    editors/jsfcstm/src/diagram、visual fixtures、
    editors/vscode/src/preview-webview/render 和预览验证脚本相对 origin/main 均无 committed diff;
  • 因而下面的修复和扫描是在与主线相同的可视化实现上增加唯一一项 nested spacing,不混入其他支线可视化代码。

视觉证据

下面两张图使用同一个真实 fixture:
editors/jsfcstm/test/fixtures/visual/09-traffic-light-stubs.fcstm

左侧使用能够复现历史回归的旧 smoother 和修复前嵌套 spacing;右侧使用本 issue 的修复方案。

full-before-after.png

局部放大可以直接看到差异:

  • BAD:两条箭头在 Red 顶边上水平行走,箭头方向与边框平行;
  • GOOD:三条箭头都从 Red 外部沿顶边法线垂直进入,并保留可见终段。

arrow-border-zoom.png

扩展历史回归矩阵:11 个正例 + 1 个负对照

为避免只用一个 traffic-light 例子,追加 12 个不同模型。左侧统一使用“历史坏 smoother +
主线 nested 默认 spacing”,右侧使用同一个历史 smoother,唯一增加
历史候选 edgeNodeBetweenLayers=20。因此差异不是更换 renderer 或 smoother 制造出来的;最终 18px 由独立的 10-fixture/20-layout 生产链路扫描复核。

前 11 个模型都实际出现目标端沿边框平行/非垂直接入,修复前每例违规数依次为
2, 7, 1, 2, 3, 3, 1, 6, 2, 6, 1,修复后全部归零。第 12 个模型本来没有该缺陷,
修复前后完全不变,作为负对照。

historical-contact-sheet-1.png

historical-contact-sheet-2.png

historical-contact-sheet-3.png

当前主线 smoother 的副作用视觉矩阵

下面三张图不使用历史 smoother。左侧是当前主线实现只移除历史候选 nested 20px,右侧是早期 20px 候选;
两边使用相同 parser、ELK、当前 smoother 和 VSCode SVG renderer。

逐图人工检查了标签遮挡、节点重叠、边穿过无关节点、crossing、折点数量和整体可读性。
12 组均未发现新增视觉冲突;可见代价是 nested composite 留白略增。

contact-sheet-1.png

contact-sheet-2.png

contact-sheet-3.png

# 模型 方向 历史目标端违规 当前布局面积变化 crossing bends 节点重叠/边穿节点
1 simple leaf TB 2 -> 0 +11.56% 0 -> 0 8 -> 8 0 -> 0
2 nested HVAC TB 7 -> 0 +15.04% 0 -> 0 26 -> 26 0 -> 0
3 deep nesting LR 1 -> 0 +2.43% 0 -> 0 2 -> 2 0 -> 0
4 many transitions TB 2 -> 0 +11.81% 0 -> 0 10 -> 10 0 -> 0
5 forced expansion LR 3 -> 0 +7.54% 0 -> 0 30 -> 30 0 -> 0
6 guard/effect TB 3 -> 0 +10.87% 0 -> 0 10 -> 10 0 -> 0
7 absolute event LR 1 -> 0 +4.05% 0 -> 0 10 -> 10 0 -> 0
8 rich state details TB 6 -> 0 +14.68% 0 -> 0 24 -> 24 0 -> 0
9 traffic-light stubs TB 2 -> 0 +5.36% 0 -> 0 8 -> 8 0 -> 0
10 visualization tutorial TB 6 -> 0 +17.46% 0 -> 0 26 -> 26 0 -> 0
11 quick-start traffic light LR 1 -> 0 +4.35% 0 -> 0 4 -> 4 0 -> 0
12 hierarchy negative control LR 0 -> 0 0.00% 0 -> 0 0 -> 0 0 -> 0

12 例的面积增幅不是全局结论,它们是刻意选择的 nested 可视化案例;这组历史 20px broad scan 的平均增幅为
4.27%,最大单布局为 18.26%。最终 18px 生产扫描另行记录平均增幅 10.25%、最大单布局 23.06%。

对应的实际 SVG path 也能直接证明差异。

历史坏形状:

M363,364 L363,430 L299,430
M217.33,240 L92,240 L92,430 L229,430

两条 path 都在 Red 的顶边 y=430 上水平结束。

修复后:

M363,384 L363,450 L299,450 L299,470
M217.33,240 L217.33,260 L92,260 L92,450 L229,450 L229,470

目标顶边为 y=470,两个终段分别是 (299,450) -> (299,470)
(229,450) -> (229,470),均为早期候选的 20px 垂直法向接入;最终 PR 的严格门禁为 18px。

可执行的几何契约

“看起来不要贴边”必须转换为可自动验证的约束。对每条非 self-loop 转换 A -> B 的每个 ELK edge section:

  1. endPoint 必须位于 B 的 top/right/bottom/left 某条边界;
  2. 倒数第二点必须位于 B 外部,不能位于 B 内部或沿边框行走;
  3. 最后一个非零线段必须沿实际接入边的外法线:
    • top/bottom 边只能由垂直线段接入;
    • left/right 边只能由水平线段接入;
  4. 终段长度必须至少为 18px;
  5. 角点不能作为漏洞:如果线段沿 top/bottom 边水平行走后停在角点,仍判定失败。

这一定义同时覆盖普通 A -> B、initial transition、exit transition 和 forced expansion;self-loop 仍保留已有的独立 spacing 规则。

历史前因

1. 首次引入 post-layout smoother

3b9210fb
引入 18px stub smoother。它尝试在 ELK 布局后移动折点来拉长箭头端和源端的短线段。

2. merge/collapse 方案消除了短线,但破坏终段方向

623575c2
改成删除短 stub 邻近折点。这个方案会改变 endpoint axis,正是上图“沿 B 边框平行结束”的来源。

3. 后续修复优先保住 endpoint axis

1f5b2aee
恢复“终段必须保持原轴”的约束;
c139ca74
统一两端阈值。

当前 smoother 因此不会主动把垂直终段旋转成水平终段,但它有一个无法规避的限制:少于 3 个点的 section 没有 bend point,可以检测,却没有可移动的几何自由度。两点直线 initial edge 和部分普通 edge 仍会保留 ELK 默认的 10px 终段。

4. self-loop spacing 已修,但普通 A -> B 未得到同类处理

396b8232
已经确认 INCLUDE_CHILDREN 不会把 root spacing 传播进嵌套 composite,因此在每个 composite 上重复设置:

elk.spacing.nodeSelfLoop = 28

但当前嵌套 composite 没有重复 edgeNodeBetweenLayers。root canvas 虽然设置了 52px,嵌套布局仍回落到 ELK 默认约 10px。这就是普通 A -> B 和 initial transition 终段过短的根因。

根因结论

问题不应继续由 post-layout smoother 主导修复。

  • smoother 只能在已有布局预算内移动折点;
  • 两点 section 根本没有折点可移动;
  • 删除折点曾经直接制造平行贴边回归;
  • root spacing 不传播到 nested composite,必须在实际拥有 children/edges 的 parent 上设置。

因此正确修复点是 ELK 布局输入阶段,让 ELK 一开始就生成足够长的法向终段。smoother 只保留为异常几何的保守兜底。

已实测的修复方案

在每个非折叠 composite 的 layoutOptions 中增加一项:

layoutOptions: {
    'elk.padding': '[top=54,left=28,bottom=28,right=28]',
    'elk.layered.spacing.edgeNodeBetweenLayers': '18',
    'elk.spacing.nodeSelfLoop': '28',
}

拟修改位置:
editors/jsfcstm/src/diagram/elk-graph.ts

早期候选 20px 曾以浮点坐标和 marker 余量为理由保留;最终 PR 采用 18px,扫描器和回归测试严格要求终段不少于 18px,不再额外支付 2px 空间。root canvas 原有的 52px 保持不变;只补充 ELK 不会继承的嵌套 composite 设置。

为什么不使用显式 FIXED_SIDE ports

对 19 个代表模型、TB/LR 共 38 次布局的对照实验中,强制 source/target ports:

  • 没有消除全部短终段;
  • bend points 从 488 增加到 636;
  • 新增 2 个 crossing;
  • 限制了 back edge 和跨层转换的自然路由。

当前 ELK 原始正交路由已经知道如何垂直接入节点;缺失的是 nested layer spacing,不是端口方向信息。因此不采用 ports。

为什么不复制全部 root spacing

把所有 root spacing 复制到每个 composite 虽然也能消除问题,但代表模型平均画布面积膨胀到约 2.23 倍。历史候选只复制 edgeNodeBetweenLayers=20,最终实现只复制 edgeNodeBetweenLayers=18

  • 历史 20px 候选平均面积增加约 4.27%,最大单图约 18.26%;
  • 最终 18px 方案由生产 scanner 实测平均增加 10.25%,最大单图 23.06%(02-nested-hvac/TB);
  • crossing 数量不变;
  • bend point 数量不变。

量化复现与修复证据

扫描范围:66 个仓库真实 FCSTM 模型,TB/LR 两种方向,共 132 次 ELK 布局、884 条 edge section。

这组 66 模型扫描由一次性只读诊断脚本完成,用于复现历史实现和比较候选参数,不准备把临时脚本本身作为 CI 接口。长期回归门禁是下文加入仓库的真实 elk.layout() 测试;它只使用 jsfcstm 自己的 visual fixture,不依赖 Python 测试树。

完整脚本和逐布局原始行已保存在
Gist 复现包,不再只依赖本机 /tmp 结果。

历史坏 smoother + 修复前 spacing

指标 数量
endpoint 未正确落在边界 1
源端非垂直 119
--> B 箭头端非垂直 188
源端短于 18px 0
箭头端短于 18px 0

这里“短终段为 0”不是成功:旧 smoother 通过删除折点消除了短线,却制造了 188 条平行贴边终段。

当前 smoother + 修复前 spacing

当前 smoother 已经避免旋转 endpoint axis,因此不再制造新的平行终段;但 nested ELK 仍只预留约
10px,超过 smoother 几何能力的两点 section 仍然过短:

指标 数量
endpoint 未正确落在边界 0
源端非垂直 0
--> B 箭头端非垂直 0
源端短于 18px 88
箭头端短于 18px 39(entry 38,普通 A -> B 1)
bend points 1086
crossing 4

这张表说明当前主线不是仍在批量制造 188 条平行箭头,而是根因仍未消除:ELK 原始布局继续产生
10px 终段,当前 smoother 只能修复其中有可移动折点的部分。

当前 smoother + 修复后 spacing

指标 数量
endpoint 未正确落在边界 0
源端非垂直 0
--> B 箭头端非垂直 0
源端短于 18px 0
箭头端短于 18px 0
bend points 1086,和修复前相同
crossing 4,和修复前相同

历史坏 smoother + 修复后 spacing

为了证明方案不是碰巧依赖当前 smoother,又把历史坏 smoother 套在修复后的布局上:

指标 数量
endpoint 未正确落在边界 0
源端非垂直 0
--> B 箭头端非垂直 0
源端短于 18px 0
箭头端短于 18px 0

原因是修复后的原始 ELK 终段已经达到至少 18px,历史 smoother 不再触发。这证明修复消除了布局根因,而不是依赖另一轮脆弱的 post-processing。

自动化回归测试

新增测试必须真实调用 elk.layout(),不能只检查 layoutOptions 字符串。

测试覆盖所有 editors/jsfcstm/test/fixtures/visual/*.fcstm,每个 fixture 同时运行 TB 和 LR,并逐条检查上面的几何契约。测试还必须:

  • 至少覆盖 100 条非 self-loop section;
  • 至少覆盖 50 条 transitionKind === 'normal' 的普通 A -> B,避免用 initial/self-loop 代替本问题;
  • 失败信息打印 fixture、方向、edge id、倒数第二点、终点和目标 bounding box。
  • 终段长度必须使用实际 endpoint-to-endpoint 距离;位置容差不能把 17.25px 线段报告成 18px。

修复前该测试会真实失败,例如:

01-simple-leaf.fcstm/TB/Switch:e1:
bottom terminal segment is only 10px; expected at least 18px

修复后测试通过。

本地验证记录

  • jsfcstm targeted ELK tests:11 passing
  • 历史 20px bundle full suite:696 passing
  • 最终 PR full suite:697 passing;coverage lines 95.75%,branches 85.89%,functions 95.96%
  • VSCode production esbuild:通过
  • 最终 production preview:verify-preview-renders.js 5/5
  • 最终 geometry scanner:10 fixtures × TB/LR = 20 layouts;fixed malformed=0short=0,最小终段严格 18px,crossingRegressions=0,平均面积 +10.25%、最大 +23.06%
  • 提交后 smoother 独立探针:176 sections(entry 40、normal 84、normalAll 38、exit 2、self 12),首段/末段轴变化均为 0;26 条 back edge 的首末轴变化也均为 0
  • repository test-boundary check:通过
  • git diff --check:通过
  • 66 模型 / 132 布局 / 884 section 外部几何扫描:零边界、垂直和最小长度违规
  • 12 组当前主线副作用对照:无新增 crossing、bend、节点重叠或边穿无关节点
  • 12 组历史回归对照:11 个正例全部从非垂直归零,1 个负对照保持不变
  • Gist 中两个脚本在隔离输出目录重跑,汇总与逐例 JSON 完全一致

复现资产

公开 Gist:
pyfcstm #376 A -> B target-border normal-entry investigation

其中包含:

  • proposed-fix.diff:生产修改和永久回归测试;
  • FINAL-18PX-VALIDATION.md:最终 PR 的 canonical 18px 验证报告;
  • report.jsonrun-preview-geometry.jsverify-preview-geometry.ts:真实 VSCode 生产链路扫描、40 个 SVG/PNG 资产及逐布局结果;
  • elk-strategy-scan.js:候选方案与 66 模型 broad scanner;
  • twelve-pair-visuals.js:12 组当前/历史对照图生成器;
  • current / historical smoother 和 VSCode SVG renderer 的诊断 bundle;
  • 19 个代表模型的全部候选策略行;
  • 当前 smoother 与历史坏 smoother 的 66 模型逐布局 JSON;
  • 12 组视觉副作用逐例指标 JSON;
  • 完整复现命令、依赖、方案取舍和验证记录。

Gist 是文本资产的稳定载体;六张 PNG 是 GitHub issue attachments,并在 Gist README 与本 issue 中双向链接。

可直接重跑的仓库验证命令

cd editors/jsfcstm
npm test

cd ../vscode
npm run compile
node ./scripts/verify-preview-interaction.js
node ./scripts/verify-preview-renders.js \
  ./../jsfcstm/test/fixtures/visual/09-traffic-light-stubs.fcstm

cd ../..
make test_boundary_check
git diff --check

其中 npm test 会重新构建 jsfcstm,并运行包含真实 ELK 几何契约在内的全量测试,避免误用修改前的陈旧 dist/

已知无关验证缺口

当前 editors/vscode 的独立 npx tsc -p ./ 仍有 5 个既有类型错误,位于 preview.tspngBase64 / pdfBase64 message typing 和 save-dialog filters。这个文件未被本修复修改;生产 esbuild、交互检查和真实 Chrome 渲染均通过。本 issue 不把这组既有类型问题冒充为本修复已解决。

合入验收标准

  • 嵌套 composite 明确设置 elk.layered.spacing.edgeNodeBetweenLayers = 18,root canvas 的 52px 保持不变
  • 保留 elk.spacing.nodeSelfLoop = 28,不把普通转换修复退化成 self-loop 专项
  • 不引入 FIXED_SIDE ports 或全量 nested spacing 复制
  • 真实 ELK 几何测试覆盖 TB/LR、普通 A -> B、initial、exit 和 forced expansion
  • 每条非 self-loop 箭头从目标外部沿目标边框法线接入,终段至少 18px
  • 历史 traffic-light fixture 不再出现沿 Red 顶边水平结束的箭头
  • jsfcstm 全量测试、VSCode production build、interaction 和 Chrome preview 全绿
  • PR 描述附带本 issue 的前后图和量化扫描结果

非目标

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: editorjsfcstm, VS Code, LSP, and editor workflows.area: visualizationPlantUML, ELK, diagrams, and rendered images.kind: bugConfirmed incorrect behavior or regression.status: completedWork was delivered and the issue is closed as completed.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions