dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) hide show
  1. package/README.md +147 -19
  2. package/cordis.patch.yml +25 -10
  3. package/lib/client.js +6400 -3326
  4. package/lib/doc-page.js +7761 -0
  5. package/lib/types/convert/host.d.ts.map +1 -1
  6. package/lib/types/convert/host.js +63 -11
  7. package/lib/types/convert/host.js.map +1 -1
  8. package/lib/types/convert/ingest.d.ts +7 -4
  9. package/lib/types/convert/ingest.d.ts.map +1 -1
  10. package/lib/types/convert/ingest.js +52 -14
  11. package/lib/types/convert/ingest.js.map +1 -1
  12. package/lib/types/convert/types.d.ts +2 -2
  13. package/lib/types/convert/types.d.ts.map +1 -1
  14. package/lib/types/convert/types.js +2 -2
  15. package/lib/types/convert/types.js.map +1 -1
  16. package/lib/types/diagnosis/cards.d.ts +8 -7
  17. package/lib/types/diagnosis/cards.d.ts.map +1 -1
  18. package/lib/types/diagnosis/cards.js +119 -49
  19. package/lib/types/diagnosis/cards.js.map +1 -1
  20. package/lib/types/diagnosis/run.d.ts +57 -0
  21. package/lib/types/diagnosis/run.d.ts.map +1 -0
  22. package/lib/types/diagnosis/run.js +245 -0
  23. package/lib/types/diagnosis/run.js.map +1 -0
  24. package/lib/types/diagnosis/schema.d.ts +25 -2
  25. package/lib/types/diagnosis/schema.d.ts.map +1 -1
  26. package/lib/types/diagnosis/schema.js +64 -25
  27. package/lib/types/diagnosis/schema.js.map +1 -1
  28. package/lib/types/diagnosis/seed-source.d.ts +17 -2
  29. package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
  30. package/lib/types/diagnosis/seed-source.js +19 -2
  31. package/lib/types/diagnosis/seed-source.js.map +1 -1
  32. package/lib/types/diagnosis/seed.d.ts +32 -42
  33. package/lib/types/diagnosis/seed.d.ts.map +1 -1
  34. package/lib/types/diagnosis/seed.js +187 -51
  35. package/lib/types/diagnosis/seed.js.map +1 -1
  36. package/lib/types/diagnosis/store.d.ts +95 -25
  37. package/lib/types/diagnosis/store.d.ts.map +1 -1
  38. package/lib/types/diagnosis/store.js +297 -166
  39. package/lib/types/diagnosis/store.js.map +1 -1
  40. package/lib/types/diagnosis/types.d.ts +168 -16
  41. package/lib/types/diagnosis/types.d.ts.map +1 -1
  42. package/lib/types/diagnosis/types.js +1 -1
  43. package/lib/types/diagnosis/types.js.map +1 -1
  44. package/lib/types/document-page.d.ts +39 -0
  45. package/lib/types/document-page.d.ts.map +1 -0
  46. package/lib/types/document-page.js +43 -0
  47. package/lib/types/document-page.js.map +1 -0
  48. package/lib/types/extract-model.d.ts +67 -27
  49. package/lib/types/extract-model.d.ts.map +1 -1
  50. package/lib/types/extract-model.js +225 -97
  51. package/lib/types/extract-model.js.map +1 -1
  52. package/lib/types/extract.d.ts +16 -0
  53. package/lib/types/extract.d.ts.map +1 -1
  54. package/lib/types/extract.js +19 -2
  55. package/lib/types/extract.js.map +1 -1
  56. package/lib/types/fs-atomic.d.ts +11 -0
  57. package/lib/types/fs-atomic.d.ts.map +1 -0
  58. package/lib/types/fs-atomic.js +41 -0
  59. package/lib/types/fs-atomic.js.map +1 -0
  60. package/lib/types/index.d.ts.map +1 -1
  61. package/lib/types/index.js +466 -63
  62. package/lib/types/index.js.map +1 -1
  63. package/lib/types/intent/types.js +1 -1
  64. package/lib/types/intent/types.js.map +1 -1
  65. package/lib/types/knowledge-bases.d.ts +23 -12
  66. package/lib/types/knowledge-bases.d.ts.map +1 -1
  67. package/lib/types/knowledge-bases.js +37 -44
  68. package/lib/types/knowledge-bases.js.map +1 -1
  69. package/lib/types/layout.d.ts +3 -3
  70. package/lib/types/layout.d.ts.map +1 -1
  71. package/lib/types/layout.js +4 -4
  72. package/lib/types/layout.js.map +1 -1
  73. package/lib/types/migrate.d.ts +1 -1
  74. package/lib/types/migrate.js +2 -2
  75. package/lib/types/migrate.js.map +1 -1
  76. package/lib/types/model-text.d.ts +99 -0
  77. package/lib/types/model-text.d.ts.map +1 -0
  78. package/lib/types/model-text.js +103 -0
  79. package/lib/types/model-text.js.map +1 -0
  80. package/lib/types/present.d.ts +239 -0
  81. package/lib/types/present.d.ts.map +1 -0
  82. package/lib/types/present.js +255 -0
  83. package/lib/types/present.js.map +1 -0
  84. package/lib/types/recall-rank.d.ts +37 -0
  85. package/lib/types/recall-rank.d.ts.map +1 -1
  86. package/lib/types/recall-rank.js +41 -1
  87. package/lib/types/recall-rank.js.map +1 -1
  88. package/lib/types/runtime.d.ts +564 -31
  89. package/lib/types/runtime.d.ts.map +1 -1
  90. package/lib/types/runtime.js +2645 -292
  91. package/lib/types/runtime.js.map +1 -1
  92. package/lib/types/settings-schema.d.ts +4 -2
  93. package/lib/types/settings-schema.d.ts.map +1 -1
  94. package/lib/types/settings-schema.js +2 -0
  95. package/lib/types/settings-schema.js.map +1 -1
  96. package/lib/types/source-images.d.ts +118 -0
  97. package/lib/types/source-images.d.ts.map +1 -0
  98. package/lib/types/source-images.js +288 -0
  99. package/lib/types/source-images.js.map +1 -0
  100. package/lib/types/types.d.ts +356 -28
  101. package/lib/types/types.d.ts.map +1 -1
  102. package/lib/types/types.js +112 -18
  103. package/lib/types/types.js.map +1 -1
  104. package/lib/types/wiki-finalize.d.ts +140 -0
  105. package/lib/types/wiki-finalize.d.ts.map +1 -0
  106. package/lib/types/wiki-finalize.js +269 -0
  107. package/lib/types/wiki-finalize.js.map +1 -0
  108. package/lib/types/wiki-types.d.ts +10 -2
  109. package/lib/types/wiki-types.d.ts.map +1 -1
  110. package/lib/types/wiki-types.js +27 -30
  111. package/lib/types/wiki-types.js.map +1 -1
  112. package/package.json +4 -3
  113. package/preset/tita-data-agent/agent.cordis.yml +25 -10
  114. package/src/client/DirectoryPicker.tsx +12 -12
  115. package/src/client/DrawerSection.tsx +18 -0
  116. package/src/client/InfoHint.tsx +21 -8
  117. package/src/client/IntentTab.tsx +20 -26
  118. package/src/client/KnowledgeBaseTab.tsx +458 -158
  119. package/src/client/KnowledgePanel.tsx +44 -10
  120. package/src/client/KnowledgeStepRail.tsx +3 -0
  121. package/src/client/KnowledgeToolRow.tsx +331 -0
  122. package/src/client/NodeSettings.tsx +20 -22
  123. package/src/client/PreviewSections.tsx +178 -43
  124. package/src/client/RecallPanel.tsx +418 -127
  125. package/src/client/TypeDefinitionDrawer.tsx +154 -54
  126. package/src/client/WikiCardReview.tsx +266 -0
  127. package/src/client/WikiFlowCard.tsx +57 -95
  128. package/src/client/WikiTypeEditor.tsx +197 -95
  129. package/src/client/arch/AgentArchitectureGraph.tsx +30 -14
  130. package/src/client/arch/NodeLogicPanel.tsx +6 -3
  131. package/src/client/arch/model.ts +164 -26
  132. package/src/client/architecture.css +25 -10
  133. package/src/client/architecture.tsx +66 -41
  134. package/src/client/asset-images.ts +125 -0
  135. package/src/client/attribution/AttributionGraph.tsx +481 -34
  136. package/src/client/attribution/AttributionTab.tsx +501 -106
  137. package/src/client/attribution/CaseView.tsx +9 -14
  138. package/src/client/attribution/GraphStage.tsx +25 -18
  139. package/src/client/attribution/RunView.tsx +27 -9
  140. package/src/client/attribution/StageChrome.tsx +280 -140
  141. package/src/client/attribution/graph-options.ts +611 -47
  142. package/src/client/attribution/model.ts +559 -30
  143. package/src/client/convert/ConvertPanel.tsx +36 -4
  144. package/src/client/doc-page.css +134 -0
  145. package/src/client/doc-page.tsx +242 -0
  146. package/src/client/index.ts +18 -0
  147. package/src/client/panel-state.ts +105 -0
  148. package/src/client/problem-domains.ts +1 -1
  149. package/src/client/request.ts +36 -0
  150. package/src/client/styles.css +397 -54
  151. package/src/client/useFitScale.ts +15 -2
  152. package/src/client/wiki-type-groups.ts +86 -0
  153. package/src/convert/host.ts +68 -9
  154. package/src/convert/ingest.ts +61 -18
  155. package/src/convert/types.ts +2 -2
  156. package/src/diagnosis/cards.ts +120 -50
  157. package/src/diagnosis/run.ts +251 -0
  158. package/src/diagnosis/schema.ts +67 -26
  159. package/src/diagnosis/seed-source.ts +29 -4
  160. package/src/diagnosis/seed.ts +187 -85
  161. package/src/diagnosis/store.ts +310 -173
  162. package/src/diagnosis/types.ts +181 -16
  163. package/src/document-page.ts +51 -0
  164. package/src/extract-model.ts +254 -99
  165. package/src/extract.ts +35 -3
  166. package/src/fs-atomic.ts +41 -0
  167. package/src/index.ts +499 -62
  168. package/src/intent/types.ts +1 -1
  169. package/src/knowledge-bases.ts +50 -49
  170. package/src/layout.ts +4 -4
  171. package/src/migrate.ts +2 -2
  172. package/src/model-text.ts +126 -0
  173. package/src/present.ts +367 -0
  174. package/src/recall-rank.ts +57 -2
  175. package/src/runtime.ts +2802 -293
  176. package/src/settings-schema.ts +2 -0
  177. package/src/source-images.ts +326 -0
  178. package/src/types.ts +446 -45
  179. package/src/wiki-finalize.ts +310 -0
  180. package/src/wiki-types.ts +28 -32
@@ -1,6 +1,7 @@
1
1
  import type { GraphData, GraphOptions, LayoutOptions } from '@antv/g6'
2
2
  import {
3
- FAILURE_MODE_STYLE, isZombieEdge, nodeBoxOf, nodeShapeOf, nodeStyleOf, problemBadgeOf,
3
+ canvasScope,
4
+ FAILURE_REASON_STYLE, isZombieEdge, nodeBoxOf, nodeShapeOf, nodeStyleOf, problemBadgeOf,
4
5
  visibleEdges, visibleNodes,
5
6
  type AttributionEdge, type AttributionGraphData, type AttributionNode, type AttributionEdgeKind,
6
7
  } from './model.ts'
@@ -44,11 +45,15 @@ export interface G6AttributionData {
44
45
 
45
46
  const FONT = '"PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif'
46
47
 
47
- /** 九种边各自的外观。层内边实线、跨层边点线,`refutes` 一律红色虚线(一等公民)。 */
48
+ /** 十种边各自的外观。层内边实线、跨层边点线,`refutes` 一律红色虚线(一等公民)。 */
48
49
  const EDGE_STYLE: Record<AttributionEdgeKind, { stroke: string; lineWidth: number; lineDash?: number[] }> = {
49
50
  may_be_caused_by: { stroke: '#93b4f8', lineWidth: 1.6 },
50
51
  verified_by: { stroke: '#6b54cf', lineWidth: 1.3 },
51
52
  categorized_as: { stroke: '#c9b8ff', lineWidth: 1.2, lineDash: [5, 4] },
53
+ // `queries`(检查点 → 资产)与 `indexed_from`(资产 → 假设)是两条边,两端与回答的
54
+ // 问题都不同,所以外观也要能分开:同色同线型的话,图上分不清「这条假设落在哪张表上」
55
+ // 与「这条检查要去查哪张表」。
56
+ queries: { stroke: '#9ec2ff', lineWidth: 1.3, lineDash: [1, 3] },
52
57
  produces: { stroke: '#b96d14', lineWidth: 1.2, lineDash: [2, 3] },
53
58
  supports: { stroke: '#12875f', lineWidth: 1.4 },
54
59
  refutes: { stroke: '#b42318', lineWidth: 1.6, lineDash: [5, 4] },
@@ -57,11 +62,12 @@ const EDGE_STYLE: Record<AttributionEdgeKind, { stroke: string; lineWidth: numbe
57
62
  indexed_from: { stroke: '#9ec2ff', lineWidth: 1.2, lineDash: [2, 3] },
58
63
  }
59
64
 
60
- /** 边的中文读法。**九种必须全有** —— 图例是通过遍历这个表生成的,漏一种就是漏一段语义。 */
65
+ /** 边的中文读法。**十种必须全有** —— 图例是通过遍历这个表生成的,漏一种就是漏一段语义。 */
61
66
  export const EDGE_KIND_LABEL: Record<AttributionEdgeKind, string> = {
62
67
  may_be_caused_by: '可能是原因',
63
68
  verified_by: '由…验证',
64
69
  categorized_as: '归类为',
70
+ queries: '要去查',
65
71
  produces: '产出',
66
72
  supports: '支持',
67
73
  refutes: '否定',
@@ -95,9 +101,40 @@ const BADGE_BASE = {
95
101
  backgroundStroke: '#e5e9f0',
96
102
  backgroundLineWidth: 1,
97
103
  backgroundRadius: 6,
98
- padding: [3, 6, 3, 6],
104
+ // 动线视角里徽标画在**胶囊内部**(见 `laneNodeStyleFor`),所以它越矮越好:
105
+ // 高度直接吃掉标题那一行的地方。9px 字是最小可读档,上下内边距压到 1。
106
+ padding: [1, 5, 1, 5],
99
107
  }
100
108
 
109
+ /**
110
+ * 节点**下方**徽标占掉的高度:徽标自身高(9px 字 + 上下内边距 ≈ 15)+ 它离节点下沿的距离(6)。
111
+ *
112
+ * **行距必须大于它。** 徽标挂在节点下方,而行距原来只有 4(面板)/ 8(全屏)——
113
+ * 于是下一行的节点正好压在上一行的徽标上:「复用 12」「3 min · 自动」「空区 · 无检查点」
114
+ * 全被盖掉一半,两行都读不出来。段底同样要留出这一段,否则最后一行照样被裁。
115
+ *
116
+ * 这一条是**几何契约**,所以 `laneLayoutOf` 直接用它,而不是各自写一个「差不多」的数:
117
+ * 徽标样式一改(字号 / 内边距 / offsetY),行距就得跟着改,否则重叠会悄悄回来。
118
+ */
119
+ const BADGE_HEIGHT = 13
120
+
121
+ /**
122
+ * 动线视角里徽标离节点**下沿内侧**的距离(负值 = 往胶囊里收)。
123
+ *
124
+ * 它原来是 `+10`(挂在节点**下方**),而那笔账在 380px 的面板里付不起:
125
+ * 徽标要占掉「徽标高 15 + 间距 10」≈ 25px 的净空,而面板五段加起来只有约 550px 预算、
126
+ * 14 行 —— 每行多 25px 就是 350px,直接翻倍。付不起的结果就是上一行的徽标被下一行的
127
+ * 节点压住(真机反馈的「都挤到一起了」)。
128
+ *
129
+ * 所以改成**画在胶囊内部的下沿**:行距不动、节点高度不动,重叠从几何上消失。
130
+ * 代价是标题收成一行(`labelMaxLines: 1`)并且让出下半部分给徽标 ——
131
+ * 完整标题在右侧详情卡里,那里有地方。
132
+ */
133
+ const BADGE_GAP = -3
134
+
135
+ /** 动线视角里标签要往上抬多少,给胶囊下沿的徽标腾地方。 */
136
+ const LANE_LABEL_OFFSET_Y = -8
137
+
101
138
  /**
102
139
  * 一个节点的完整样式。
103
140
  *
@@ -108,7 +145,11 @@ const BADGE_BASE = {
108
145
  * 空区用「红色虚环 + 空区徽标」双重表达:只染灰不够 —— 维护者扫一眼时会把颜色
109
146
  * 读成失败模式配色,只有虚线会被读成「这里少了东西」。
110
147
  */
111
- export function nodeStyleFor(node: AttributionNode, gapIds: ReadonlySet<string>): Record<string, unknown> {
148
+ export function nodeStyleFor(
149
+ node: AttributionNode,
150
+ gapIds: ReadonlySet<string>,
151
+ options: { dimmed?: boolean } = {},
152
+ ): Record<string, unknown> {
112
153
  const [width, height] = nodeBoxOf(node)
113
154
  const shape = nodeShapeOf(node)
114
155
  const isGap = gapIds.has(node.id)
@@ -150,7 +191,7 @@ export function nodeStyleFor(node: AttributionNode, gapIds: ReadonlySet<string>)
150
191
  // 假设节点会先命中 hypothesis 分支,写成 else-if 的话空区徽标永远不会出现,
151
192
  // 而空区恰恰总是假设节点。
152
193
  badges.push(isGap ? gapBadge : { ...BADGE_BASE, text: `复用 ${node.reuseCount ?? 0}`, placement: 'top', offsetY: -10 })
153
- } else if (node.kind === 'check') {
194
+ } else if (node.kind === 'check_point') {
154
195
  badges.push({ ...BADGE_BASE, text: `${node.costMinutes ?? '?'} min · ${node.automation === 'auto' ? '自动' : '人工'}`, placement: 'bottom', offsetY: 10, fill: node.automation === 'auto' ? '#12875f' : '#b96d14' })
155
196
  } else if (isGap) {
156
197
  badges.push(gapBadge)
@@ -160,10 +201,106 @@ export function nodeStyleFor(node: AttributionNode, gapIds: ReadonlySet<string>)
160
201
  style.fillOpacity = 0.45
161
202
  style.labelOpacity = 0.55
162
203
  }
204
+ // 「其余节点降到 0.35」(§7.4 第 9 条)。徽标一起淡掉并且**去掉描边** ——
205
+ // 不淡的话,被点亮的那条链会被旁边一堆白底描边的小徽标压住,等于没点亮。
206
+ if (options.dimmed) {
207
+ style.fillOpacity = DIM_OPACITY
208
+ style.strokeOpacity = DIM_OPACITY
209
+ style.labelOpacity = DIM_OPACITY
210
+ if (badges.length > 0) {
211
+ style.badges = badges.map(badge => ({
212
+ ...badge,
213
+ fillOpacity: DIM_OPACITY,
214
+ backgroundFill: 'transparent',
215
+ backgroundStroke: 'transparent',
216
+ backgroundLineWidth: 0,
217
+ }))
218
+ }
219
+ }
220
+ return style
221
+ }
222
+
223
+ /** 未被点亮时的透明度。写在一处,画布与节点详情读同一个数。 */
224
+ export const DIM_OPACITY = 0.35
225
+
226
+ /**
227
+ * 动线视角里的节点样式:**胶囊,不是圆**。
228
+ *
229
+ * 关系视图里假设画成圆(圆的直径就是复用次数,那一段读的是「这块密不密」)。
230
+ * 动线视角读的是另一件事 ——「这条链走到哪」—— 所以每个节点都要能读出名字,
231
+ * 复用次数退成徽标上的一行字:`H1 上游分区未产出` + `复用 12`。
232
+ */
233
+ export function laneNodeStyleFor(node: AttributionNode, gapIds: ReadonlySet<string>, options: {
234
+ size: [number, number]
235
+ dimmed?: boolean
236
+ }): Record<string, unknown> {
237
+ const [width, height] = options.size
238
+ const isGap = gapIds.has(node.id)
239
+ const palette = nodeStyleOf(node)
240
+ const style: Record<string, unknown> = {
241
+ size: [width, height],
242
+ radius: 8,
243
+ fill: palette.fill,
244
+ stroke: isGap ? GAP_STROKE : palette.stroke,
245
+ lineWidth: isGap ? 2 : 1.5,
246
+ lineDash: isGap ? [4, 3] : undefined,
247
+ zIndex: node.layer === 'instance' ? 0 : 1,
248
+ // id 必须留在标签里:它是案例路径、排查清单与这个节点之间唯一的指认方式。
249
+ labelText: `${node.id} ${node.title}`,
250
+ labelPlacement: 'center',
251
+ labelFill: palette.text,
252
+ labelFontSize: 10.5,
253
+ labelFontFamily: FONT,
254
+ labelFontWeight: 600,
255
+ // 标签往上抬,把胶囊下半部分让给徽标(见 `LANE_LABEL_OFFSET_Y`)。
256
+ labelOffsetY: LANE_LABEL_OFFSET_Y,
257
+ labelWordWrap: true,
258
+ labelWordWrapWidth: width - 12,
259
+ // **一行。** 两行标题 + 一枚徽标装不进 36px 的胶囊;而两行标题装得下、
260
+ // 徽标挂在下面时,行距必须让出 25px —— 那笔账在 380px 的面板里付不起。
261
+ // 取舍写在 §7.7.1.3:动线视角保「不重叠 + 五段一眼看完」,完整标题归右侧详情卡。
262
+ labelMaxLines: 1,
263
+ labelTextOverflow: 'ellipsis',
264
+ cursor: 'pointer',
265
+ }
266
+
267
+ const badges: Array<Record<string, unknown>> = []
268
+ // 段头已经写了段的读法,所以徽标只留「这个节点最关键的那一格」:一段一个。
269
+ // 都堆上去的话,44px 高的胶囊会被三个徽标切成碎片。
270
+ if (node.kind === 'symptom') {
271
+ const problem = problemBadgeOf(node)
272
+ if (problem) badges.push({ ...BADGE_BASE, text: problem, placement: 'bottom', offsetY: BADGE_GAP, fill: '#96580f', backgroundStroke: '#f2ddb8' })
273
+ } else if (node.kind === 'hypothesis') {
274
+ badges.push(isGap
275
+ ? { ...BADGE_BASE, text: '空区 · 无检查点', placement: 'bottom', offsetY: BADGE_GAP, fill: GAP_STROKE, backgroundStroke: '#f0b7ad' }
276
+ : { ...BADGE_BASE, text: `复用 ${node.reuseCount ?? 0}`, placement: 'bottom', offsetY: BADGE_GAP, fill: palette.text, backgroundStroke: palette.stroke })
277
+ } else if (node.kind === 'check_point') {
278
+ badges.push({ ...BADGE_BASE, text: `${node.costMinutes ?? '?'} min · ${node.automation === 'auto' ? '自动' : '人工'}`, placement: 'bottom', offsetY: BADGE_GAP, fill: node.automation === 'auto' ? '#12875f' : '#b96d14' })
279
+ } else if (node.kind === 'evidence') {
280
+ const verdict = node.verdict === 'refutes' ? '否定' : node.verdict === 'supports' ? '支持' : '给不出结论'
281
+ badges.push({ ...BADGE_BASE, text: verdict, placement: 'bottom', offsetY: BADGE_GAP, fill: node.verdict === 'refutes' ? GAP_STROKE : '#12875f' })
282
+ } else if (isGap) {
283
+ badges.push({ ...BADGE_BASE, text: '空区 · 无检查点', placement: 'bottom', offsetY: BADGE_GAP, fill: GAP_STROKE, backgroundStroke: '#f0b7ad' })
284
+ }
285
+ if (badges.length > 0) style.badges = badges
286
+ if (options.dimmed) {
287
+ style.fillOpacity = DIM_OPACITY
288
+ style.strokeOpacity = DIM_OPACITY
289
+ style.labelOpacity = DIM_OPACITY
290
+ if (badges.length > 0) {
291
+ style.badges = badges.map(badge => ({
292
+ ...badge,
293
+ fillOpacity: DIM_OPACITY,
294
+ backgroundFill: 'transparent',
295
+ backgroundStroke: 'transparent',
296
+ backgroundLineWidth: 0,
297
+ }))
298
+ }
299
+ }
163
300
  return style
164
301
  }
165
302
 
166
- export function edgeStyleFor(edge: AttributionEdge): Record<string, unknown> {
303
+ export function edgeStyleFor(edge: AttributionEdge, options: { labels?: boolean } = {}): Record<string, unknown> {
167
304
  const zombie = isZombieEdge(edge)
168
305
  const base = zombie ? ZOMBIE_STYLE : EDGE_STYLE[edge.kind]
169
306
  const style: Record<string, unknown> = {
@@ -180,6 +317,9 @@ export function edgeStyleFor(edge: AttributionEdge): Record<string, unknown> {
180
317
  labelBackgroundRadius: 4,
181
318
  labelPadding: [2, 4, 2, 4],
182
319
  }
320
+ // 动线视角里二十几条边并排成五段,标签会互相压成一片 —— 那一段的读法已经写在
321
+ // 段头(`orderHint`)上,边上只留箭头。关系视图没有段头,标签才是唯一读法。
322
+ if (options.labels === false) return style
183
323
  if (zombie) {
184
324
  style.labelText = `僵尸边 ${edge.staleDays} 天未走`
185
325
  style.labelFontSize = 9
@@ -195,44 +335,371 @@ export function edgeStyleFor(edge: AttributionEdge): Record<string, unknown> {
195
335
  /**
196
336
  * 模型 → G6 数据。
197
337
  *
198
- * `includeInstance` 默认 false:实例层随每次排查线性增长,展开它会把类型层挤成
199
- * 一堆小点 —— 而图谱视图要看的第一件事恰是类型层收不收敛。
338
+ * 位置分两种来源:关系视图用 `positions`(黄金角螺旋的确定性初始位置,再交给
339
+ * 力导向松弛);动线视图用 `laneGeometry`(段位与段内顺序**完全**决定坐标)。
340
+ * 两者都不依赖随机数 —— 同一份数据刷新之后,空间记忆仍然成立(§7.2)。
200
341
  */
201
342
  export function toG6Data(
202
343
  data: AttributionGraphData,
203
- options: { includeInstance?: boolean; gapIds: ReadonlySet<string>; positions?: Map<string, [number, number]> } = { gapIds: new Set() },
344
+ options: {
345
+ includeInstance?: boolean
346
+ gapIds: ReadonlySet<string>
347
+ positions?: Map<string, [number, number]>
348
+ /** 动线视角的段位坐标。给了它就用段位,`positions` 被忽略。 */
349
+ laneGeometry?: LaneGeometry
350
+ /** 被点亮的那条链之外的节点:透明度降到 0.35(§7.4 第 9 条)。 */
351
+ dimmedIds?: ReadonlySet<string>
352
+ /** 边上是否画标签。动线视角关掉(段头已经写了读法)。 */
353
+ edgeLabels?: boolean
354
+ /**
355
+ * 布局。它决定「哪些节点与边根本不进这张画布」——
356
+ * 动线视角不画没有段位的节点种类(`failure_reason`)与反向的 `indexed_from`
357
+ * (见 `canvasScope`)。
358
+ *
359
+ * **必填,没有默认值。** 给个默认会让调用方在不知情的情况下拿到「少画了东西」的
360
+ * 那张图 —— 而少画是静默的:节点就是不在那儿,没有任何报错。要哪一种,说清楚。
361
+ */
362
+ layout: 'line' | 'force'
363
+ },
204
364
  ): G6AttributionData {
205
- const nodes = visibleNodes(data, options.includeInstance ?? false)
206
- const edges = visibleEdges(data, nodes)
365
+ const scope = canvasScope(options.layout)
366
+ /**
367
+ * **段位几何是「画什么」的唯一真源。**
368
+ *
369
+ * 动线视角下只画 `geometry.nodes` 里有的节点。少了这一条会同时踩两个坑:
370
+ * ① 没有段位的节点(`failure_reason`)会退到力导向的种子位置上,变成悬在画布上的
371
+ * 孤儿 —— 它不在任何一段里,却在图上占一个位置;
372
+ * ② 被收起的段(④ 查询依据)里的节点照样会被画出来,而段头写的是「已收起」——
373
+ * 画布与段头对不上,读数就成了假的。
374
+ */
375
+ const all = visibleNodes(data, options.includeInstance ?? false).filter(node => !scope.kinds.has(node.kind))
376
+ const nodes = options.laneGeometry ? all.filter(node => options.laneGeometry!.nodes.has(node.id)) : all
377
+ const edges = visibleEdges(data, nodes).filter(edge => !scope.edges.has(edge.kind))
378
+ const dimmed = options.dimmedIds ?? new Set<string>()
207
379
  return {
208
380
  nodes: nodes.map(node => {
209
- const style = nodeStyleFor(node, options.gapIds)
210
- // 力导向布局的初始位置:给了就落上去再松弛,没给就让 G6 自己随机。
211
- const seed = options.positions?.get(node.id)
212
- if (seed) { style.x = seed[0]; style.y = seed[1] }
213
- return { id: node.id, type: nodeShapeOf(node), data: { node }, style }
381
+ const lane = options.laneGeometry?.nodes.get(node.id)
382
+ const style = lane
383
+ ? laneNodeStyleFor(node, options.gapIds, { size: [lane.width, lane.height], dimmed: dimmed.has(node.id) })
384
+ : nodeStyleFor(node, options.gapIds, { dimmed: dimmed.has(node.id) })
385
+ if (lane) {
386
+ style.x = lane.x
387
+ style.y = lane.y
388
+ } else {
389
+ const seed = options.positions?.get(node.id)
390
+ if (seed) { style.x = seed[0]; style.y = seed[1] }
391
+ }
392
+ // 动线视角一律画成胶囊:段内横排,形状不参与表达(那是关系视图的活)。
393
+ return { id: node.id, type: lane ? 'rect' : nodeShapeOf(node), data: { node }, style }
214
394
  }),
215
395
  edges: edges.map(edge => ({
216
396
  id: edge.id,
217
397
  source: edge.source,
218
398
  target: edge.target,
219
399
  data: { edge },
220
- style: edgeStyleFor(edge),
400
+ style: {
401
+ ...edgeStyleFor(edge, options.edgeLabels === false ? { labels: false } : {}),
402
+ ...(dimmed.has(edge.id) ? { dimmed: true } : {}),
403
+ },
221
404
  })),
222
405
  }
223
406
  }
224
407
 
408
+ /* ────────────────────────────────────────────────────────────────────────────
409
+ * 动线视角的段位(0021 §7.2 / §7.7)
410
+ *
411
+ * 位置**完全**由「段位 + 段内顺序」决定,所以它是纯函数:同一份数据、同一个容器,
412
+ * 每次算出来的坐标一模一样。这也是「上次那个空区在第三段」这类空间记忆能成立的原因。
413
+ * ──────────────────────────────────────────────────────────────────────────── */
414
+
415
+ /** 一段的尺寸预算。上下留白不是审美:段头要写在带子里,节点下方还挂着徽标。 */
416
+ export interface LaneMetrics {
417
+ /** 段头带高(段名 + 计数写在这里)。 */
418
+ header: number
419
+ /**
420
+ * 段间距的上限(把剩余高度平分进段间距时的封顶)。
421
+ *
422
+ * 不封顶会踩到全屏:容器 1200px、内容只有 500px 时,剩下 700px 平分进 4 个段间距
423
+ * = 每段之间空 175px。五段本来是**一条动线上的五站**,空成这样读起来像五张互不相干
424
+ * 的图。封顶之后多出来的空间进 `offsetY`,整块内容垂直居中 —— 那才是「留白」,
425
+ * 而不是「断开」。
426
+ */
427
+ maxBandGap: number
428
+ /**
429
+ * 段内容底部要留的高度。
430
+ *
431
+ * 现在是 0:徽标画在胶囊内部,不再有东西挂到节点下方。留着这个字段是因为
432
+ * 「有没有东西挂在节点外面」是会变的(关系视图的徽标就挂在外面),
433
+ * 到那时它是唯一该改的地方。
434
+ */
435
+ bandBottomPad: number
436
+ /**
437
+ * 内容左右留白。左侧留够浮层压过来的宽度。
438
+ *
439
+ * 面板态给 100:左栏的 pill / 展开面板都浮在画布左上,实测宽约 90px。
440
+ * 段头文字与节点都从这一列开始,它们才不会被浮层盖住 —— 而「段头读不到」
441
+ * 等于这个视角没了标题。
442
+ */
443
+ padLeft: number
444
+ padRight: number
445
+ rowGap: number
446
+ /** 段与段之间的最小间距。剩余空间会**平分加到这里**。 */
447
+ bandGap: number
448
+ /** 每种节点的胶囊尺寸(宽 × 高)。 */
449
+ sizeOf: (node: AttributionNode) => [number, number]
450
+ }
451
+
452
+ /**
453
+ * 面板 / 全屏两档的段位尺寸。
454
+ *
455
+ * 面板档算得很紧:380px 宽、四五百像素高的边栏里,五段要能一眼看完 ——
456
+ * 看不完就退化成「一张排好序的清单」,而动线视角的价值恰在「这条链断在哪」。
457
+ * 横向预算按 380 − 56 − 16 = 308 给:现象一行两个、检查点一行两个、资产一行三个。
458
+ * **行数由内容算**(见 `laneLayoutOf` 的 `packLane`),不写死上限 —— 某一段特别密
459
+ * 就多占一行,而不是被裁掉;五段都留在视野里靠的是「行数取最少」加整体收一点。
460
+ * 全屏放得下,节点尺寸与段间距都放宽一档。
461
+ *
462
+ * 左侧 56px 是留给左栏浮层的:面板态它收起成一颗药丸(约 90px 宽,26px 高),
463
+ * 只压住第一行节点的左端 —— 比把每一段都往右推 90px 划算得多(那会只剩 270px
464
+ * 可用宽,检查点会被迫排到四行,五段就铺不进一屏了)。
465
+ */
466
+ export const LANE_METRICS: Record<StageMode, LaneMetrics> = {
467
+ panel: {
468
+ // 行距不动:徽标画在胶囊内部,不占行间净空(见 `BADGE_GAP`)。
469
+ header: 20, padLeft: 56, padRight: 16, rowGap: 4, bandGap: 6, maxBandGap: 22,
470
+ bandBottomPad: 0,
471
+ sizeOf: (node) => {
472
+ if (node.kind === 'symptom') return [124, 38]
473
+ if (node.kind === 'hypothesis') return [118, 36]
474
+ if (node.kind === 'check_point') return [102, 30]
475
+ if (node.kind === 'asset') return [102, 28]
476
+ return [160, 30]
477
+ },
478
+ },
479
+ fullscreen: {
480
+ header: 24, padLeft: 96, padRight: 20, rowGap: 8, bandGap: 14, maxBandGap: 48,
481
+ bandBottomPad: 0,
482
+ sizeOf: (node) => {
483
+ if (node.kind === 'symptom') return [210, 48]
484
+ if (node.kind === 'hypothesis') return [176, 44]
485
+ if (node.kind === 'check_point') return [184, 40]
486
+ if (node.kind === 'asset') return [150, 34]
487
+ return [200, 40]
488
+ },
489
+ },
490
+ }
491
+
492
+ export function laneMetricsFor(mode: StageMode): LaneMetrics {
493
+ return LANE_METRICS[mode]
494
+ }
495
+
496
+ /** 动线视角里一个节点的落位:中心坐标 + 尺寸。单位是**世界坐标**(zoom = 1 时等于画布像素)。 */
497
+ export interface LaneNodePlacement { x: number; y: number; width: number; height: number }
498
+
499
+ /**
500
+ * 一段的几何:整条带子的上下边界、段头的下沿、内容区起点、这一段的节点落位。
501
+ *
502
+ * `top` / `bottom` 是**世界坐标**,可以直接喂给 `laneGuides()` 算泳道带矩形 ——
503
+ * 画布与 HTML 泳道带读同一份结果,两处不可能对不上(§7.7 段头与泳道背景)。
504
+ */
505
+ export interface LaneGeometry {
506
+ /** 整块内容的宽度(世界坐标)。 */
507
+ contentWidth: number
508
+ /** 容器高度减内容高度的一半,用来把内容垂直居中;为负说明装不下。 */
509
+ offsetY: number
510
+ bands: Array<{
511
+ laneId: string
512
+ top: number
513
+ bottom: number
514
+ headerBottom: number
515
+ /** 段的节点区起点(世界 x)。段头文字与节点左对齐到它。 */
516
+ x: number
517
+ rowCount: number
518
+ nodeCount: number
519
+ }>
520
+ nodes: Map<string, LaneNodePlacement>
521
+ }
522
+
523
+ /**
524
+ * 一段的入参:段 id + 已经排好序的节点。
525
+ *
526
+ * 刻意**只收这两个字段**而不是整个 `LaneSegment`:布局只关心「谁、什么顺序」,
527
+ * 段名与计数是段头的事。两边不互相依赖,`graph-options` 也就不必 import `model` 的
528
+ * 段类型。
529
+ */
530
+ export interface LaneBandInput {
531
+ laneId: string
532
+ nodes: AttributionNode[]
533
+ }
534
+
535
+ /**
536
+ * 按段位算坐标。
537
+ *
538
+ * 算法两步,都不含随机:
539
+ * 1. **段内**:按传入顺序从左到右摆;一行摆不下就换行(节点宽度不同,
540
+ * 所以必须真的累加宽度,不能按「每行 N 个」估)。每段的行数上限取
541
+ * `rowBudget[laneId]`:**超出的行不撑高带子**,五段因此永远留在视野里 ——
542
+ * 「哪一段断了」是这张图第一要读的东西,某一段特别密不该把它挤出屏幕。
543
+ * 2. **段间**:每段高 = 段头 + 行高之和,再加上 `bandGap`;`viewportHeight` 给了
544
+ * 就把剩余空间**平分进段间距**,让五段铺满高度。剩余不足时 `offsetY` **夹在 0**
545
+ * —— 那说明容器太矮,宁可下缘被裁,也不要上缘(段头所在的那一条)被推出画面。
546
+ */
547
+ export function laneLayoutOf(
548
+ bands: LaneBandInput[],
549
+ options: {
550
+ width: number
551
+ height: number
552
+ mode: StageMode
553
+ /** 期望的内容总高(通常 = 容器高)。省略就按 `bandGap` 自然堆叠。 */
554
+ viewportHeight?: number
555
+ },
556
+ ): LaneGeometry {
557
+ const metrics = laneMetricsFor(options.mode)
558
+ const contentWidth = Math.max(240, options.width)
559
+ const inner = Math.max(120, contentWidth - metrics.padLeft - metrics.padRight)
560
+
561
+ /**
562
+ * 段内怎么摆:**先求这一段至少要几行,再把节点均分进那几行**。
563
+ *
564
+ * 不是「填满一行换下一行」—— 那样最后一行常常只剩一个节点,其余全挤在前面几行,
565
+ * 而横向预算又不允许超出去,于是最后那个节点会被压得很小。均分是同一个段里
566
+ * 唯一能让每个节点尺寸接近的做法。
567
+ *
568
+ * 行数取「内容总宽 ÷ 可用宽」向上取整 —— **尽量少占行数**,五段才铺得进一屏。
569
+ * 每一行再按可用宽整体收一点(`scale`),这样最宽的节点也不会越出画布右沿;
570
+ * 收的是同一段内所有的节点,所以它们看起来仍然是一组。
571
+ */
572
+ const packLane = (nodes: AttributionNode[], factor: number): { rows: AttributionNode[][]; widths: number[][]; heights: number[][] } => {
573
+ if (nodes.length === 0) return { rows: [], widths: [], heights: [] }
574
+ const sizes = nodes.map(node => {
575
+ const [w, h] = metrics.sizeOf(node)
576
+ return [Math.round(w * factor), Math.round(h * factor)] as [number, number]
577
+ })
578
+ const totalWidth = sizes.reduce((sum, size) => sum + size[0], 0) + ROW_NODE_GAP * (nodes.length - 1)
579
+ const rowCount = Math.max(1, Math.ceil(totalWidth / inner))
580
+ const perRow = Math.ceil(nodes.length / rowCount)
581
+ const rows: AttributionNode[][] = []
582
+ for (let start = 0; start < nodes.length; start += perRow) rows.push(nodes.slice(start, start + perRow))
583
+ const widest = rows.reduce((max, row) => {
584
+ const content = row.reduce((sum, node) => sum + (sizes[nodes.indexOf(node)]?.[0] ?? 0), 0) + ROW_NODE_GAP * (row.length - 1)
585
+ return Math.max(max, content)
586
+ }, 0)
587
+ const squeeze = Math.min(1, inner / Math.max(1, widest))
588
+ const widths = rows.map(row => row.map(node => Math.max(48, Math.round((sizes[nodes.indexOf(node)]?.[0] ?? 100) * squeeze))))
589
+ const heights = rows.map(row => row.map(node => Math.max(24, Math.round((sizes[nodes.indexOf(node)]?.[1] ?? 36) * squeeze))))
590
+ return { rows, widths, heights }
591
+ }
592
+
593
+ /** 五段铺在给定缩放下的总高。自适应那一步要拿它比对可用高。 */
594
+ const totalHeightAt = (factor: number): number => {
595
+ let total = 0
596
+ for (const band of bands) {
597
+ const pack = packLane(band.nodes, factor)
598
+ const rowHeights = pack.heights.map(row => Math.max(...row, 0))
599
+ total += metrics.header + (rowHeights.length === 0
600
+ ? EMPTY_ROW_HEIGHT
601
+ : rowHeights.reduce((sum, h) => sum + h, 0) + metrics.rowGap * (rowHeights.length - 1))
602
+ }
603
+ return total + metrics.bandGap * Math.max(0, bands.length - 1)
604
+ }
605
+
606
+ /**
607
+ * 内容铺不下时整体收一点,而不是让下缘被裁掉。
608
+ *
609
+ * 面板档的段位尺寸是按「380px 宽、约 500px 高」定的;边栏高度随宿主的窗口变,
610
+ * 用户也可能把窗口拉得很矮。这时按 `√(可用高 ÷ 需要高)` 把每个节点等比收一点,
611
+ * 五段就仍然留在一屏里 —— 而「哪一段断了」必须始终看得见。
612
+ *
613
+ * 下界 0.78:再小标签就读不出来了。到那一步宁可让下缘溢出去(此时 `offsetY`
614
+ * 夹在 0,被裁的只会是最后一段的下沿,段头永远在画面里)。
615
+ */
616
+ const fitFactor = ((): number => {
617
+ if (options.viewportHeight === undefined) return 1
618
+ if (totalHeightAt(1) <= options.viewportHeight) return 1
619
+ const guess = Math.max(0.78, Math.sqrt(options.viewportHeight / totalHeightAt(1)))
620
+ // 收完之后每行装得下更多节点,行数可能变少 —— 再校一次,两次足够收敛。
621
+ if (totalHeightAt(guess) <= options.viewportHeight) return guess
622
+ return Math.max(0.78, guess * Math.sqrt(options.viewportHeight / totalHeightAt(guess)))
623
+ })()
624
+
625
+ const packed = bands.map(band => ({ band, pack: packLane(band.nodes, fitFactor) }))
626
+ const naturalHeights = packed.map(item => {
627
+ const rowHeights = item.pack.heights.map(row => Math.max(...row, 0))
628
+ return metrics.header + (rowHeights.length === 0
629
+ ? EMPTY_ROW_HEIGHT
630
+ : rowHeights.reduce((sum, h) => sum + h, 0) + metrics.rowGap * (rowHeights.length - 1) + metrics.bandBottomPad)
631
+ })
632
+ const gaps = Math.max(0, packed.length - 1)
633
+ const natural = naturalHeights.reduce((sum, h) => sum + h, 0) + metrics.bandGap * gaps
634
+ const slack = options.viewportHeight === undefined ? 0 : options.viewportHeight - natural
635
+ // 剩余空间平分给段间距,但**封顶**(见 `maxBandGap`):装不下时退回自然间距
636
+ // (负的 slack 不参与),富余很多时多出来的那部分进下面的 `offsetY`,整块居中。
637
+ const bandGap = metrics.bandGap + (slack > 0 && gaps > 0
638
+ ? Math.min(slack / gaps, metrics.maxBandGap - metrics.bandGap)
639
+ : 0)
640
+ const total = naturalHeights.reduce((sum, h) => sum + h, 0) + bandGap * gaps
641
+
642
+ const geometry: LaneGeometry = {
643
+ contentWidth,
644
+ // 夹在 0:容器装不下时宁可下缘被裁,也不要段头被推出画面(见函数头)。
645
+ offsetY: options.viewportHeight === undefined ? 0 : Math.max(0, (options.viewportHeight - total) / 2),
646
+ bands: [],
647
+ nodes: new Map(),
648
+ }
649
+
650
+ let cursor = geometry.offsetY
651
+ packed.forEach((item, index) => {
652
+ const height = naturalHeights[index]!
653
+ const rowHeights = item.pack.heights.map(row => Math.max(...row, 0))
654
+ const top = cursor
655
+ const headerBottom = top + metrics.header
656
+ const band = {
657
+ laneId: item.band.laneId,
658
+ top,
659
+ bottom: top + height,
660
+ headerBottom,
661
+ x: metrics.padLeft,
662
+ rowCount: item.pack.rows.length,
663
+ nodeCount: item.band.nodes.length,
664
+ }
665
+ geometry.bands.push(band)
666
+
667
+ let y = headerBottom + (rowHeights.length === 0 ? EMPTY_ROW_HEIGHT / 2 : 0)
668
+ item.pack.rows.forEach((row, rowIndex) => {
669
+ const rowHeight = rowHeights[rowIndex] ?? 0
670
+ let x = metrics.padLeft
671
+ row.forEach((node, nodeIndex) => {
672
+ const w = item.pack.widths[rowIndex]?.[nodeIndex] ?? 100
673
+ const h = item.pack.heights[rowIndex]?.[nodeIndex] ?? rowHeight
674
+ geometry.nodes.set(node.id, { x: x + w / 2, y: y + rowHeight / 2, width: w, height: h })
675
+ x += w + ROW_NODE_GAP
676
+ })
677
+ y += rowHeight + metrics.rowGap
678
+ })
679
+ cursor = band.bottom + bandGap
680
+ })
681
+
682
+ return geometry
683
+ }
684
+
685
+ /** 同一行里两个节点之间的间距。 */
686
+ const ROW_NODE_GAP = 8
687
+ /** 空段的高度:足以放下一行「这一段还没有东西」的说明。 */
688
+ const EMPTY_ROW_HEIGHT = 24
689
+
225
690
  /**
226
- * 布局种类。
691
+ * 布局种类。**只有两种,而且默认是动线。**
227
692
  *
228
- * `force` 是知识图谱的默认读法:没有「上→下」的方向,关系靠聚簇和距离表达,
229
- * 节点可以拖,像一张会自己摊开的地图。
230
- * `layered` 是**语义**读法:现象→假设→检查本来就是有向的 DAG,分层把这个方向
231
- * 显式画出来 —— 代价是它看起来像流程图而不是知识图谱。
693
+ * `line`(动线)是默认读法:五段泳道,位置完全由段位与段内排序决定(§7.7)。
694
+ * 它回答的是值班同学打开这一栏真正要问的那句 ——「下一步查什么、这条链断在哪」。
695
+ * `force`(关系)是自由读法:浮动节点、可拖拽,关系靠聚簇与距离表达。
696
+ * 它回答「这张图长什么样、哪块密哪里空」,那是维护者的问题。
232
697
  *
233
- * 两个都留:默认 force 回答「这张图长什么样」,切到 layered 回答「谁是上游」。
698
+ * **v1.4 撤销了「分层」(`layered`)。** 原来留它是因为「方向本身是语义」——
699
+ * 而动线就是这件事的语义版:分层只按入度分列,动线按这五个阶段分列,且每段带段头
700
+ * 与计数。两个都能画方向时,留那个说得清「这一段是什么」的(§7.2)。
234
701
  */
235
- export type GraphLayout = 'force' | 'layered'
702
+ export type GraphLayout = 'line' | 'force'
236
703
 
237
704
  /**
238
705
  * 画布所处的容器档位。
@@ -299,10 +766,28 @@ export function seedPositions(data: AttributionGraphData, width: number, height:
299
766
  return positions
300
767
  }
301
768
 
302
- export function layoutOf(kind: GraphLayout, rankdir: 'LR' | 'TB'): LayoutOptions {
303
- if (kind === 'layered') {
304
- return { type: 'antv-dagre', rankdir, nodesep: 46, ranksep: 96, controlPoints: false }
305
- }
769
+ /**
770
+ * 布局配置。
771
+ *
772
+ * **动线视角不给 G6 任何布局算法**:坐标已经由 `laneLayoutOf` 算好了,
773
+ * 这里只需一个「原样保留我给的坐标」的布局。G6 的 `grid` 在数据里带了 `x` / `y`
774
+ * 时会原样采用,因此这是最省的一个 —— 引入 d3-force 或 dagre 都会把我们的段位
775
+ * 重新摆过一遍,那正是「段位固定」的反面。
776
+ */
777
+ /**
778
+ * 动线视角**没有布局算法** —— 它返回 `undefined`,而不是 `{ type: 'grid' }`。
779
+ *
780
+ * 这一格出过一次很难看的错:写着 `{ type: 'grid' }`,看着像「不布局,摆成网格」,
781
+ * 实际是让 G6 **真的去跑一遍网格布局**,把 `style.x` / `style.y` 全部重写掉。
782
+ * 于是段位算出来的坐标被整份丢弃,节点散落到网格上,而泳道带还画在段位坐标上 ——
783
+ * 带子与节点彻底对不上。相机又锁在 `zoom: 1` 且没有 `autoFit`,网格铺得比容器大,
784
+ * 大部分节点直接跑到可视区之外,屏幕上只剩几个徽标和一堆穿出来的边。
785
+ *
786
+ * 动线的坐标来自 `laneLayoutOf`(段位 + 段内顺序,纯函数、每次一样),
787
+ * 所以这里**必须什么都不做**:G6 在没有 `layout` 时才会用数据里给的坐标。
788
+ */
789
+ export function layoutOf(kind: GraphLayout, rankdir: 'LR' | 'TB'): LayoutOptions | undefined {
790
+ if (kind === 'line') return undefined
306
791
  return {
307
792
  type: 'd3-force',
308
793
  // 值都偏大:节点带下方徽标,挤在一起时徽标先重叠,比圆重叠更难看。
@@ -316,36 +801,75 @@ export function layoutOf(kind: GraphLayout, rankdir: 'LR' | 'TB'): LayoutOptions
316
801
  }
317
802
  }
318
803
 
804
+ /**
805
+ * 交互行为。
806
+ *
807
+ * 动线视角**刻意关掉 `drag-canvas` 与 `zoom-canvas`**,只留点击:
808
+ * 画布上的泳道带是叠在画布上的 HTML(`LaneBands`),它按**世界坐标**摆位。
809
+ * 相机一旦平移或缩放,带子与节点立刻错开 —— 而「段头 + 泳道」正是这个视角的
810
+ * 全部信息。锁住相机是这套对齐成立的前提,不是省事。
811
+ *
812
+ * 关系视图照旧:画布可拖可缩放,节点可拽。
813
+ */
319
814
  export function behaviorsOf(kind: GraphLayout): string[] {
815
+ if (kind === 'line') return ['click-select']
320
816
  // force 布局必须用 drag-element-force:它把拖动写回模拟,松手后图会继续摊开;
321
817
  // 用 drag-element 的话拖动只改坐标,力模拟会在下一帧把节点拽回去。
322
- return kind === 'force'
323
- ? ['drag-canvas', 'zoom-canvas', 'click-select', 'drag-element-force']
324
- : ['drag-canvas', 'zoom-canvas', 'click-select', 'drag-element']
818
+ return ['drag-canvas', 'zoom-canvas', 'click-select', 'drag-element-force']
819
+ }
820
+
821
+ /**
822
+ * 动线视角的缩放。**固定 1:1**:世界坐标 = 屏幕像素,HTML 泳道带才能与节点对齐。
823
+ * 关系视图不锁,用 `zoomRangeFor` 的区间。
824
+ */
825
+ export const LANE_ZOOM = 1
826
+
827
+ /** 动线视角里的留白。段位自己带留白,所以这里只留一点点,别把内容挤到中间。 */
828
+ export const LANE_PADDING: Record<StageMode, [number, number, number, number]> = {
829
+ panel: [0, 0, 0, 0],
830
+ fullscreen: [0, 0, 0, 0],
325
831
  }
326
832
 
327
833
  /**
328
834
  * G6 实例配置。
329
835
  *
330
- * `nodesep` 取 46 而不是默认的 10:假设节点下方挂了标题徽标,行距必须容得下它,
331
- * 否则同一层里相邻两个节点的徽标会叠在一起。
332
- *
333
836
  * **背景是纯白的,而且刻意不加 `grid-line` 插件。** 井字格在截图和演示里能撑场面,
334
- * 但它在这张图上是纯噪声:节点位置由力导向决定,网格暗示的「坐标」并不存在;
335
- * 而这张图上真正要读的是「哪块密、哪里空」,底色越干净越好。
837
+ * 但它在这张图上是纯噪声:关系视图里节点位置由力导向决定,网格暗示的「坐标」并不
838
+ * 存在;动线视图里位置由段位决定,网格更没有意义。而这张图真正要读的是「哪块密、
839
+ * 哪里空」与「哪一段断了」,底色越干净越好(§7.1 两条硬约束之一)。
336
840
  */
337
841
  export function buildGraphOptions(
338
842
  container: { width: number; height: number },
339
- layout: GraphLayout = 'force',
843
+ layout: GraphLayout = 'line',
340
844
  rankdir: 'LR' | 'TB' = 'LR',
341
845
  mode: StageMode = 'panel',
342
846
  ): GraphOptions {
847
+ const line = layout === 'line'
343
848
  return {
344
849
  width: container.width,
345
850
  height: container.height,
346
- background: '#ffffff',
347
- autoFit: { type: 'view', options: { when: 'overflow' } },
348
- padding: paddingFor(mode),
851
+ /**
852
+ * **这里没有 `background`,而且不能加。**
853
+ *
854
+ * G6 v5 默认开 `enableMultiLayer`,会在容器里叠四层 canvas,并把 `background`
855
+ * 只给最底下那层 `background` —— 那是一个**不透明的白色画布**,尺寸铺满容器。
856
+ * 而容器是泳道带那一层的**后一个兄弟节点**(DOM 顺序在后 = 画在上面),
857
+ * 且 G6 还给容器设了 `isolation: isolate`。结果就是:**泳道带整个被这块白布盖住**,
858
+ * 一个字都看不见 —— 而「① 问题现象 3」这些段头正是 §7.7 的核心读数。
859
+ *
860
+ * 用无头 Chrome 复刻过四种组合(见 `tests/attribution-layer-order.test.mjs` 的注释):
861
+ * 只有「去掉 background」或「把带子挪到画布之后」能让带子出现,两者都做才同时
862
+ * 满足「看得见」与「点得到」。
863
+ *
864
+ * 另外它还是个主题 bug:写死 `#ffffff` 之后,深色主题下画布照样是白的。
865
+ * 底色由外层容器的 `bg-tita-surface` 提供 —— 那一个跟着主题走。
866
+ */
867
+ // 动线视角不能自适应:`autoFit` 会把段位缩放着塞进容器,泳道带随之错位。
868
+ // 关系视图照旧自适应(那是「哪块密」最直观的一档)。
869
+ ...(line
870
+ ? { zoom: LANE_ZOOM }
871
+ : { autoFit: { type: 'view' as const, options: { when: 'overflow' as const } } }),
872
+ padding: line ? LANE_PADDING[mode] : paddingFor(mode),
349
873
  zoomRange: zoomRangeFor(mode),
350
874
  animation: false,
351
875
  layout: layoutOf(layout, rankdir),
@@ -353,14 +877,20 @@ export function buildGraphOptions(
353
877
  state: {
354
878
  selected: { lineWidth: 3, stroke: '#2563eb' },
355
879
  active: { lineWidth: 2.6, stroke: '#2563eb' },
880
+ // 「其余节点透明度降到 0.35」(§7.4 第 9 条)。状态的样式值走 `var()` 共享
881
+ // 令牌,`DIM_OPACITY` 是它的数值孪生 —— 节点数据在纯函数里算出来时也要用同一
882
+ // 个数(node:test 里读不到 CSS),两处不同步就会一半亮一半暗。
883
+ dim: { fillOpacity: DIM_OPACITY, strokeOpacity: DIM_OPACITY, labelOpacity: DIM_OPACITY },
356
884
  },
357
885
  },
358
886
  edge: {
359
- // 正交折线是给分层布局用的;力导向里没有「行」的概念,折线反而更乱,
360
- // 用二次曲线让交叉点少一点。
361
- type: layout === 'force' ? 'quadratic' : 'polyline',
362
- style: layout === 'force' ? { curveOffset: 22 } : { radius: 8, router: { type: 'orth' } },
363
- state: { selected: { lineWidth: 2.4, stroke: '#2563eb' } },
887
+ // 段位布局的边一律走折线:起点与终点在相邻两段,折线比曲线更容易顺着读。
888
+ type: line ? 'polyline' : 'quadratic',
889
+ style: line ? { radius: 6 } : { curveOffset: 22 },
890
+ state: {
891
+ selected: { lineWidth: 2.4, stroke: '#2563eb' },
892
+ dim: { strokeOpacity: DIM_OPACITY, labelOpacity: DIM_OPACITY },
893
+ },
364
894
  },
365
895
  behaviors: behaviorsOf(layout),
366
896
  }
@@ -375,7 +905,41 @@ export function buildGraphOptions(
375
905
  */
376
906
  export function toGraphData(
377
907
  data: AttributionGraphData,
378
- options: { includeInstance?: boolean; gapIds: ReadonlySet<string>; positions?: Map<string, [number, number]> },
908
+ /**
909
+ * 与 `toG6Data` **同一份**入参类型。
910
+ *
911
+ * 抄第二份字段清单的下场是它们迟早不一致 —— 而表现是「某个开关在画布上不生效」,
912
+ * 因为类型在这一层被悄悄丢掉了(加 `layout` 时就正好踩到)。
913
+ */
914
+ options: Parameters<typeof toG6Data>[1],
379
915
  ): GraphData {
380
916
  return toG6Data(data, options) as unknown as GraphData
381
917
  }
918
+
919
+ /**
920
+ * 画布上的段位矩形(泳道带)。
921
+ *
922
+ * 它是 `LaneGeometry` 的**屏幕坐标**视图:HTML 覆盖层按它画带子,画布按同一份
923
+ * `LaneGeometry` 摆节点。两处共用一套坐标,所以「段头写在哪、哪一段的右边没有线」
924
+ * 不需要靠眼力对齐。
925
+ */
926
+ export interface LaneGuide {
927
+ laneId: string
928
+ top: number
929
+ left: number
930
+ width: number
931
+ height: number
932
+ /** 段头带的下沿(相对 guide 顶部)。 */
933
+ headerHeight: number
934
+ }
935
+
936
+ export function laneGuides(geometry: LaneGeometry): LaneGuide[] {
937
+ return geometry.bands.map(band => ({
938
+ laneId: band.laneId,
939
+ top: band.top,
940
+ left: 0,
941
+ width: geometry.contentWidth,
942
+ height: band.bottom - band.top,
943
+ headerHeight: band.headerBottom - band.top,
944
+ }))
945
+ }