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,8 +1,14 @@
1
1
  import { Graph } from '@antv/g6'
2
- import { useEffect, useRef, useState, type ReactNode } from 'react'
3
- import { buildGraphOptions, seedPositions, toGraphData, type GraphLayout, type StageMode } from './graph-options.ts'
2
+ import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
4
3
  import { cn } from '@tita-data-agent/ui/components'
5
- import type { AttributionGraphData } from './model.ts'
4
+ import {
5
+ buildGraphOptions, laneGuides, laneLayoutOf, laneMetricsFor, seedPositions, toGraphData,
6
+ type GraphLayout, type LaneGeometry, type StageMode,
7
+ } from './graph-options.ts'
8
+ import {
9
+ chainOf, chainStartAllowed, laneBandsOf, laneSegments,
10
+ type AttributionGraphData, type LaneId, type LaneSegment,
11
+ } from './model.ts'
6
12
 
7
13
  export interface AttributionGraphProps {
8
14
  data: AttributionGraphData
@@ -10,76 +16,253 @@ export interface AttributionGraphProps {
10
16
  gapIds: ReadonlySet<string>
11
17
  /**
12
18
  * 布局方向。**它随视图模式变**,不是用户偏好:
13
- * 承载面板的右侧边栏只有 380px(宿主 `width:380px`),所以布局的长宽比必须
19
+ * 承载面板的右侧边栏只有 380px(宿主 `width:380px`),所以关系布局的长宽比必须
14
20
  * 配合视口 —— 全景用 LR(竖长,贴住竖屏视口),聚焦用 TB(紧凑,让 7 个节点
15
- * 能按接近 1:1 渲染而不是被压成一半)。
21
+ * 能按接近 1:1 渲染而不是被压成一半)。动线视角忽略它:段位决定一切。
16
22
  */
17
23
  rankdir: 'LR' | 'TB'
18
- /** `force` 浮动节点(默认);`layered` 分层 DAG。 */
24
+ /** `line` 动线(默认,五段泳道);`force` 关系(浮动节点)。 */
19
25
  layout: GraphLayout
20
- /** 视口参数档位:面板内 / 全屏。决定缩放区间与留白。 */
26
+ /** 视口参数档位:面板内 / 全屏。决定缩放区间、留白与段位尺寸。 */
21
27
  mode: StageMode
28
+ /** 选中节点:它的那条链被点亮,其余降到 0.35(§7.4 第 9 条)。 */
22
29
  selectedId?: string
23
- onSelect: (id: string) => void
30
+ /** 是否展开 ⑤ 段(实例层)。被点亮的链上的实例节点不受它限制。 */
31
+ includeInstance?: boolean
32
+ /**
33
+ * 被用户收起的段(④ 查询依据默认收起)。收起是「有数据但不画」——
34
+ * 与「这一段是空的」是两件事,段头必须分开说(见 `LaneSegment.collapsed`)。
35
+ */
36
+ collapsedLanes?: ReadonlySet<LaneId>
37
+ /** 点段头收起 / 展开它。 */
38
+ onToggleLane?: (lane: LaneId) => void
39
+ /** 被切面关掉的段(`kinds` 里没有这一段的任何一种节点)。 */
40
+ lanesOff?: ReadonlySet<LaneId>
41
+ /** ① 段只画这一个现象;其余现象进段头的切换入口。 */
42
+ focusSymptomId?: string
43
+ /** 点段头上的现象入口:换到那个现象的动线。 */
44
+ onPickSymptom?: (id: string) => void
45
+ onSelect: (id: string | undefined) => void
46
+ /** 段头读数(每段画了几个 / 一共几个)同步给左栏的段 chip。 */
47
+ onLaneCounts?: (counts: Record<LaneId, { shown: number; total: number }>) => void
24
48
  className?: string
25
49
  }
26
50
 
27
51
  /**
28
- * G6 v5 画布。
52
+ * 每一段的底色色相。
53
+ *
54
+ * 参照的是配图 `assets/0021-智能归因图谱-动线视角布局.svg`:现象与实例偏琥珀
55
+ * (同一个问题的两头)、假设偏蓝、检查点偏紫、查询依据中性。段与段因此有分区感,
56
+ * 而色相不承担语义 —— 语义在段名与节点配色上(避免「蓝到底是哪一段」这种读法)。
57
+ */
58
+ const LANE_TINT: Record<LaneId, string> = {
59
+ symptom: 'var(--tita-tone-amber)',
60
+ hypothesis: 'var(--tita-tone-blue)',
61
+ check_point: 'var(--tita-tone-violet)',
62
+ asset: 'var(--tita-ink-faint)',
63
+ instance: 'var(--tita-tone-amber)',
64
+ }
65
+
66
+ /** 空集:没有段被收起 / 关掉时的默认值。写成常量是为了它每次渲染都是同一个引用。 */
67
+ const EMPTY_LANES: ReadonlySet<LaneId> = new Set<LaneId>()
68
+
69
+ /** 五段一条都没有时的初值:段 chip 的数字必须**始终有值**。 */
70
+ const ZERO_COUNTS: Record<LaneId, { shown: number; total: number }> = {
71
+ symptom: { shown: 0, total: 0 },
72
+ hypothesis: { shown: 0, total: 0 },
73
+ check_point: { shown: 0, total: 0 },
74
+ asset: { shown: 0, total: 0 },
75
+ instance: { shown: 0, total: 0 },
76
+ }
77
+
78
+ function countsOf(segments: LaneSegment[]): Record<LaneId, { shown: number; total: number }> {
79
+ const counts = { ...ZERO_COUNTS }
80
+ for (const segment of segments) counts[segment.lane.id] = { shown: segment.nodes.length, total: segment.total }
81
+ return counts
82
+ }
83
+
84
+ /**
85
+ * G6 v5 画布 + 动线视角的泳道带。
29
86
  *
30
87
  * **它铺满父容器,自己不带高度。** 尺寸由父级用 CSS 决定(`absolute inset-0` 或
31
88
  * 普通 `h-full`),画布只负责把 `clientWidth/clientHeight` 读出来 —— 这样「图谱占满
32
89
  * 整个内容区」是布局层的事,画布不需要知道「面板有多高」这种知识,全屏切换也就
33
90
  * 只是父容器换了个尺寸,画布跟着 ResizeObserver 走。
34
91
  *
35
- * 实例策略:**在布局方向、档位或数据变化时重建 Graph,只有选中态变化时才走
36
- * `setElementState`**。重建一个 20 多节点的图只要几十毫秒,而增量同步布局、
37
- * 样式、标签的边界情况(G6 会缓存节点位置与 state)远比一次重建更容易出错 ——
38
- * 这个取舍随图上到几千节点才需要反过来。
92
+ * 两条关于坐标的硬约束(动线视角的地基):
93
+ * 1. **相机锁在 `[0, 0, 1]`**(`buildGraphOptions` 里不给 `autoFit`、给 `zoom: 1`,
94
+ * 行为里不带 `drag-canvas` / `zoom-canvas`)。世界坐标因此等于屏幕像素,
95
+ * 叠在画布上的 HTML 泳道带才可能与节点对齐。
96
+ * 2. **段位由 `laneLayoutOf` 一次性算好**,节点的 `x` / `y` / `size` 直接写进数据,
97
+ * G6 那边只给一个「原样采用我给的坐标」的布局。
98
+ *
99
+ * 实例策略:**只在布局、档位或数据变化时重建 Graph**;选中态、段展开与尺寸变化都走
100
+ * 增量(`setElementState` / `updateNodeData` / `setData` + `draw`)。重建一个二十多
101
+ * 节点的图只要几十毫秒,但每次点节点都重建会让高亮闪一下,还会把关系视图里拖动过的
102
+ * 位置吹散。
39
103
  */
40
- export function AttributionGraph({ data, gapIds, rankdir, layout, mode, selectedId, onSelect, className }: AttributionGraphProps): ReactNode {
104
+ export function AttributionGraph({
105
+ data, gapIds, rankdir, layout, mode, selectedId, includeInstance = false, onSelect, onLaneCounts, className,
106
+ collapsedLanes = EMPTY_LANES,
107
+ focusSymptomId,
108
+ onPickSymptom,
109
+ onToggleLane,
110
+ lanesOff = EMPTY_LANES
111
+ }: AttributionGraphProps): ReactNode {
41
112
  const host = useRef<HTMLDivElement>(null)
42
113
  const graphRef = useRef<Graph | null>(null)
43
114
  const selectRef = useRef(onSelect)
44
115
  selectRef.current = onSelect
116
+ const countsRef = useRef(onLaneCounts)
117
+ countsRef.current = onLaneCounts
45
118
  const dataRef = useRef(data)
46
119
  dataRef.current = data
120
+ const gapRef = useRef(gapIds)
121
+ gapRef.current = gapIds
122
+
123
+ /** 一条被点亮的链:从现象 / 假设出发,沿有语义方向的边往前走(§7.4 第 9 条)。 */
124
+ const chainIds = useMemo(() => {
125
+ if (!selectedId) return undefined
126
+ const start = data.nodes.find(node => node.id === selectedId)
127
+ if (!chainStartAllowed(start)) return undefined
128
+ return chainOf(data, selectedId)
129
+ }, [data, selectedId])
130
+
131
+ /** 要被压暗的元素:链之外的节点,以及两端不都在链上的边。 */
132
+ const dimmed = useMemo(() => {
133
+ if (!chainIds) return { nodes: new Set<string>(), edges: new Set<string>() }
134
+ const edges = new Set<string>()
135
+ for (const edge of data.edges) {
136
+ // 边的判据是「**两端都不在链上**」:一端在链上的边恰恰是这条链与外界的关系
137
+ // (它从这里接出去),压暗它就等于把链的出口抹掉。
138
+ if (!(chainIds.has(edge.source) || chainIds.has(edge.target))) edges.add(edge.id)
139
+ }
140
+ return { nodes: new Set(data.nodes.filter(node => !chainIds.has(node.id)).map(node => node.id)), edges }
141
+ }, [data, chainIds])
142
+
143
+ /** 五段的分段结果。段头读数与泳道带都读它,纯函数所以每次渲染重算即可。 */
144
+ const segments = useMemo(
145
+ // 被点亮的那条链上的实例节点在折叠态下仍然画出来(§7.7 末段)。
146
+ () => laneSegments(data, { includeInstance, collapsedLanes, ...(focusSymptomId ? { focusSymptomId } : {}), ...(chainIds ? { chainIds } : {}) }),
147
+ [data, includeInstance, chainIds],
148
+ )
149
+
150
+ /** 段头读数同步给左栏。 */
151
+ useEffect(() => {
152
+ countsRef.current?.(countsOf(segments))
153
+ }, [segments])
154
+
155
+ const [geometry, setGeometry] = useState<LaneGeometry | undefined>(undefined)
47
156
  // 重建之后选中态要重新贴上去:新节点上没有任何 state,只靠 selectedId 的
48
157
  // 变化触发不了那个 effect。
49
- const [renderToken, setRenderToken] = useState(0)
158
+ const [renderToken, bumpRender] = useState(0)
159
+
160
+ const chainKey = chainIds ? [...chainIds].sort().join(',') : ''
161
+ /**
162
+ * 收起态的**内容指纹**。
163
+ *
164
+ * `collapsedLanes` 是一个 `Set`,每次渲染都是新对象 —— 直接进依赖数组会让增量
165
+ * effect 每一帧都跑一遍(重算段位、重下数据、重画)。指纹只认内容。
166
+ */
167
+ const collapsedKey = [...collapsedLanes].sort().join(',')
168
+ // 换一个当前现象 = 换一整条动线,所以要重算段位并重下数据(与收起态同一路)。
169
+ const focusKey = focusSymptomId ?? ''
170
+
171
+ /**
172
+ * 重建 effect 里要用到「当前的链」与「当前的展开态」,但它们**不该触发重建**
173
+ * (那是下面两个增量 effect 的事)—— 所以走 ref 读当前值,不进依赖数组。
174
+ */
175
+ const chainRef = useRef<ReadonlySet<string> | undefined>(chainIds)
176
+ chainRef.current = chainIds
177
+ const includeRef = useRef(includeInstance)
178
+ includeRef.current = includeInstance
179
+ const collapsedRef = useRef(collapsedLanes)
180
+ collapsedRef.current = collapsedLanes
181
+ const focusRef = useRef(focusSymptomId)
182
+ focusRef.current = focusSymptomId
183
+ /**
184
+ * 重建 effect 已经跑过一轮了吗?
185
+ *
186
+ * 下面那个增量 effect 与它挂载时的依赖相同 —— 不挡一下,首帧就会**下发两遍数据、
187
+ * 画两次**(后一次还会把节点位置重新算一遍)。它只在「段展开 / 高亮链真的变了」
188
+ * 时才该跑;重建 effect 一开跑就把它压回 false,那一轮的变化由重建自己承担。
189
+ */
190
+ const builtRef = useRef(false)
50
191
 
51
192
  useEffect(() => {
52
193
  const el = host.current
53
194
  if (!el) return
195
+ builtRef.current = false
54
196
  const measure = (): { width: number; height: number } => ({
55
197
  // 兜底尺寸:容器还没被布局出来时(首帧、或宿主面板宽度为 0)先给一个能画的值,
56
198
  // 免得 G6 拿到 0 之后整块空白。
57
199
  width: el.clientWidth || 348,
58
200
  height: el.clientHeight || 360,
59
201
  })
202
+
203
+ /**
204
+ * 段位几何。关系视图没有段 —— 但**仍然返回一个几何**:调用方要拿它统一构造
205
+ * 数据(`laneGeometry` 给了就按段位摆,不给就走 `positions`)。
206
+ */
207
+ const computeGeometry = (size: { width: number; height: number }): LaneGeometry | undefined => {
208
+ if (layout !== 'line') return undefined
209
+ return laneLayoutOf(
210
+ laneBandsOf(laneSegments(dataRef.current, {
211
+ includeInstance: includeRef.current,
212
+ collapsedLanes: collapsedRef.current,
213
+ ...(focusRef.current ? { focusSymptomId: focusRef.current } : {}),
214
+ ...(chainRef.current ? { chainIds: chainRef.current } : {}),
215
+ })),
216
+ { width: size.width, height: size.height, mode, viewportHeight: size.height },
217
+ )
218
+ }
219
+
220
+ const size = measure()
221
+ const first = computeGeometry(size)
222
+ const positions = layout === 'line' ? undefined : seedPositions(dataRef.current, size.width, size.height)
60
223
  const graph = new Graph({
61
- ...buildGraphOptions(measure(), layout, rankdir, mode),
224
+ ...buildGraphOptions(size, layout, rankdir, mode),
62
225
  container: el,
63
226
  })
64
227
  graphRef.current = graph
228
+
65
229
  graph.on('node:click', event => {
66
230
  // G6 的 IEvent 是联合类型,取 id 需要一次窄化;断言限制在这一行内。
67
231
  const id = (event as { target?: { id?: string } }).target?.id
68
232
  if (id) selectRef.current(id)
69
233
  })
70
- const size = measure()
71
- // 只有力导向才需要喂初始位置:分层布局的坐标由 dagre 算,喂了也会被覆盖。
72
- const positions = layout === 'force' ? seedPositions(data, size.width, size.height) : undefined
73
- graph.setData(toGraphData(data, { gapIds, ...(positions ? { positions } : {}) }))
74
- void graph.render().then(() => setRenderToken(token => token + 1))
234
+ // 点空白处取消点亮(§7.4 第 9 条)。
235
+ graph.on('canvas:click', () => selectRef.current(undefined))
236
+
237
+ graph.setData(toGraphData(dataRef.current, {
238
+ layout,
239
+ gapIds: gapRef.current,
240
+ ...(positions ? { positions } : {}),
241
+ ...(first ? { laneGeometry: first, edgeLabels: false } : {}),
242
+ }))
243
+ setGeometry(first)
244
+ void graph.render().then(() => {
245
+ builtRef.current = true
246
+ bumpRender(token => token + 1)
247
+ })
75
248
 
76
249
  // 宿主面板宽度可被用户拖动,全屏切换也会改变尺寸,画布必须跟着走,
77
- // 否则会留下一块空白或裁掉右侧。
250
+ // 否则会留下一块空白或裁掉右侧。动线视角还要**重算段位**:段位依赖容器宽高
251
+ // (换行点、垂直居中),只改画布尺寸会让节点与泳道带错开。
78
252
  const observed = new ResizeObserver(() => {
79
253
  const next = measure()
80
254
  if (next.width <= 0 || next.height <= 0) return
255
+ const [w, h] = graph.getSize()
256
+ if (Math.abs(w - next.width) < 2 && Math.abs(h - next.height) < 2) return
81
257
  graph.setSize(next.width, next.height)
82
- void graph.fitView()
258
+ if (layout !== 'line') {
259
+ void graph.fitView()
260
+ return
261
+ }
262
+ const lane = computeGeometry(next)
263
+ if (!lane) return
264
+ applyPlacements(graph, lane, dataRef.current)
265
+ setGeometry(lane)
83
266
  })
84
267
  observed.observe(el)
85
268
  return () => {
@@ -87,18 +270,282 @@ export function AttributionGraph({ data, gapIds, rankdir, layout, mode, selected
87
270
  graph.destroy()
88
271
  graphRef.current = null
89
272
  }
90
- }, [data, gapIds, rankdir, layout, mode])
273
+ // 段展开与高亮链由下面两个 effect 负责(它们走增量,不重建);
274
+ // 这里只读它们的**当前值**,不跟着重建。
275
+ // eslint-disable-next-line react-hooks/exhaustive-deps
276
+ }, [data, layout, rankdir, mode])
91
277
 
278
+ /**
279
+ * 段展开 / 高亮链会让「哪些节点在图上」变化。这是**数据变化**,不能只改状态,
280
+ * 所以走一次数据下发;坐标复用当前段位算法,不重建 Graph(拖动过位置的
281
+ * 关系视图不受影响 —— 它本来也不走这条分支)。
282
+ */
92
283
  useEffect(() => {
93
284
  const graph = graphRef.current
94
- if (!graph || !selectedId) return
95
- // 选中节点可能被筛选掉了:G6 的 `setElementState` 对未知 id 是**抛异常**,
96
- // 不是静默忽略(`data.getElementType` 里 `throw new Error`)。所以先确认它还在图上。
97
- if (!dataRef.current.nodes.some(node => node.id === selectedId)) return
98
- void graph.setElementState(selectedId, ['selected'])
99
- // 上一个选中节点必须显式取消,否则连着点三个节点会留下三个蓝色描边。
100
- return () => { void graph.setElementState(selectedId, []) }
101
- }, [selectedId, renderToken])
102
-
103
- return <div ref={host} className={cn('h-full w-full overflow-hidden bg-tita-surface', className)} />
285
+ const el = host.current
286
+ // 首轮由重建 effect 已经下发过了(见 `builtRef`);这里只处理**之后**的变化。
287
+ if (!builtRef.current || !graph || !el || layout !== 'line') return
288
+ const size = { width: el.clientWidth || 348, height: el.clientHeight || 360 }
289
+ const lane = laneLayoutOf(
290
+ laneBandsOf(laneSegments(dataRef.current, {
291
+ includeInstance, collapsedLanes,
292
+ ...(focusSymptomId ? { focusSymptomId } : {}),
293
+ ...(chainIds ? { chainIds } : {}),
294
+ })),
295
+ { width: size.width, height: size.height, mode, viewportHeight: size.height },
296
+ )
297
+ graph.setData(toGraphData(dataRef.current, {
298
+ layout,
299
+ gapIds: gapRef.current, laneGeometry: lane, edgeLabels: false, includeInstance,
300
+ }))
301
+ setGeometry(lane)
302
+ void graph.draw()
303
+ // `chainKey` 是 `chainIds` 的内容指纹:Set 每次渲染都是新对象,直接依赖它
304
+ // 会让这个 effect 每一帧都跑一遍。
305
+ // eslint-disable-next-line react-hooks/exhaustive-deps
306
+ }, [includeInstance, chainKey, collapsedKey, focusKey, layout, mode])
307
+
308
+ /**
309
+ * 选中态:**只走 `setElementState`,不重建整张图**(§7.4 第 9 条)。
310
+ *
311
+ * 三种状态一起下发:链上的节点是 `active`(描边加粗),用户点的那个是 `selected`,
312
+ * 其余是 `dim`(透明度 0.35)。链之外的边也一起压暗 —— 只淡节点的话,二十几条边
313
+ * 在暗节点之间仍然亮着,画面会碎成一片。
314
+ */
315
+ useEffect(() => {
316
+ const graph = graphRef.current
317
+ if (!graph) return
318
+ const onGraph = new Set(dataRef.current.nodes.map(node => node.id))
319
+ const states: Record<string, string[]> = {}
320
+ if (chainIds) {
321
+ for (const id of chainIds) if (onGraph.has(id)) states[id] = id === selectedId ? ['selected'] : ['active']
322
+ for (const id of dimmed.nodes) if (onGraph.has(id)) states[id] = ['dim']
323
+ const edgeIds = new Set(dataRef.current.edges.map(edge => edge.id))
324
+ for (const id of dimmed.edges) if (edgeIds.has(id)) states[id] = ['dim']
325
+ } else if (selectedId && onGraph.has(selectedId)) {
326
+ // 起点不允许点亮(资产 / 检查点 / 失败原因):仍然给一个选中描边,
327
+ // 否则用户点了没反应,看起来像坏了。
328
+ states[selectedId] = ['selected']
329
+ }
330
+ if (Object.keys(states).length === 0) return
331
+ void graph.setElementState(states)
332
+ }, [chainIds, chainKey, dimmed, selectedId, renderToken])
333
+
334
+ return <div className={cn('relative h-full w-full overflow-hidden bg-tita-surface', className)}>
335
+ {/*
336
+ 三层,顺序就是层叠顺序(都没有 z-index),**每一层的层叠要求正好相反**:
337
+
338
+ 1. 段底色 —— 必须在节点**下面**。`color-mix()` 出来的是不透明色,排在画布之后
339
+ 会把节点连同标题整片盖住(A/B/C 对照量过:带底色的覆盖层一压上去,
340
+ 胶囊与标题全没了,只剩位置更高的徽标)。
341
+ 2. 画布 —— G6 的容器。**不能给它 `background`**:G6 v5 会把它变成一块铺满的
342
+ 不透明画布,把底下的带子整个盖住(见 `buildGraphOptions` 的注释)。
343
+ 3. 段头 —— 必须在画布**上面**才能收到点击。G6 的容器是铺满的 `absolute inset-0`,
344
+ 谁在 DOM 里靠后谁拿到 `elementFromPoint`。整层 `pointer-events-none`,
345
+ 只有段头那一小条是 `auto`,所以「点节点看链」照旧落到画布上。
346
+ */}
347
+ {layout === 'line' && geometry && <LaneTints geometry={geometry} segments={segments} lanesOff={lanesOff} />}
348
+ <div ref={host} className="absolute inset-0" />
349
+ {layout === 'line' && geometry && <LaneHeaders
350
+ geometry={geometry}
351
+ segments={segments}
352
+ mode={mode}
353
+ lanesOff={lanesOff}
354
+ {...(onToggleLane ? { onToggleLane } : {})}
355
+ {...(onPickSymptom ? { onPickSymptom } : {})}
356
+ />}
357
+ </div>
358
+ }
359
+
360
+ function LaneTints({ geometry, segments, lanesOff }: {
361
+ geometry: LaneGeometry
362
+ segments: LaneSegment[]
363
+ lanesOff: ReadonlySet<LaneId>
364
+ }): ReactNode {
365
+ const byId = new Map(segments.map(segment => [segment.lane.id, segment]))
366
+ return <div
367
+ data-tita-lane-tints=""
368
+ className="pointer-events-none absolute inset-0 overflow-hidden"
369
+ style={{ width: geometry.contentWidth }}
370
+ >
371
+ {laneGuides(geometry).map(guide => {
372
+ const segment = byId.get(guide.laneId as LaneId)
373
+ if (!segment) return null
374
+ return <div
375
+ key={guide.laneId}
376
+ className="tita-attrBand"
377
+ data-tita-lane-state={laneStateOf(segment, lanesOff)}
378
+ style={{
379
+ position: 'absolute',
380
+ left: guide.left,
381
+ top: guide.top,
382
+ width: guide.width,
383
+ height: guide.height,
384
+ // 段的底色走高混合:主题一换,带子跟着走,不需要第二份颜色。
385
+ // 色相跟着配图(现象琥珀 / 假设蓝 / 检查点紫 / 依据中性 / 实例琥珀)——
386
+ // 段与段之间因此有分区感,而同色相的两个节点种类本来就在相邻两段。
387
+ ['--tita-band-tint' as string]: LANE_TINT[segment.lane.id],
388
+ }}
389
+ />
390
+ })}
391
+ </div>
392
+ }
393
+
394
+ /**
395
+ * 段头(段名 / 计数 / 读法 / 现象切换入口)。
396
+ *
397
+ * **它与段底色分在两层,因为两者的层叠要求正好相反:**
398
+ *
399
+ * - 底色必须在节点**下面** —— 排在画布之后会盖住节点。`color-mix()` 出来的是
400
+ * 不透明色(5% 色相 + 95% 白,alpha = 1),压上去之后节点胶囊与标题整片消失,
401
+ * 只剩画布上位置更高的徽标露在外面。这一条是拿 A/B/C 三组对照量出来的:
402
+ * A 无覆盖层 → 节点正常;B 覆盖层带底色 → 节点与标题全没了;C 覆盖层透明底 → 正常。
403
+ * - 段头必须在画布**上面** —— 排在画布之前就收不到点击(G6 的容器铺满整个区域)。
404
+ *
405
+ * 合成一层的话,二者必坏其一。
406
+ */
407
+ function LaneHeaders({ geometry, segments, mode, lanesOff, onToggleLane, onPickSymptom }: {
408
+ geometry: LaneGeometry
409
+ segments: LaneSegment[]
410
+ /** 面板 / 全屏。它决定段头放得下几个现象入口(见 `PICK_LIMIT`)。 */
411
+ mode: StageMode
412
+ /** 被切面关掉的段。它与「收起」「空」是三件不同的事。 */
413
+ lanesOff: ReadonlySet<LaneId>
414
+ onToggleLane?: (lane: LaneId) => void
415
+ /** 点①段段头上的现象入口:换到那个现象的动线。 */
416
+ onPickSymptom?: (id: string) => void
417
+ }): ReactNode {
418
+ const byId = new Map(segments.map(segment => [segment.lane.id, segment]))
419
+ return <div
420
+ data-tita-lane-heads=""
421
+ className="pointer-events-none absolute inset-0 overflow-hidden"
422
+ style={{ width: geometry.contentWidth }}
423
+ >
424
+ {laneGuides(geometry).map(guide => {
425
+ const segment = byId.get(guide.laneId as LaneId)
426
+ if (!segment) return null
427
+ const shown = segment.nodes.length
428
+ const readout = laneReadoutOf(segment, lanesOff)
429
+ // 有现象入口时段头右侧留给入口:`1/5` 已经说明了「库里五个、这屏一个」,
430
+ // 再挤一句排序依据进去只会换行。
431
+ const picks = segment.lane.id === 'symptom' ? segment.hidden : []
432
+ const limit = PICK_LIMIT[mode]
433
+ return <div
434
+ key={guide.laneId}
435
+ className="tita-attrBandHeader pointer-events-auto"
436
+ style={{
437
+ position: 'absolute',
438
+ left: guide.left,
439
+ top: guide.top,
440
+ width: guide.width,
441
+ height: guide.headerHeight,
442
+ ['--tita-band-tint' as string]: LANE_TINT[segment.lane.id],
443
+ }}
444
+ >
445
+ <button
446
+ type="button"
447
+ className="tita-attrBandHeadMain"
448
+ disabled={!readout.action || !onToggleLane}
449
+ aria-expanded={readout.action ? !segment.collapsed : undefined}
450
+ title={readout.text}
451
+ onClick={() => onToggleLane?.(segment.lane.id)}
452
+ >
453
+ <span className="tita-attrBandName">{segment.lane.ordinal} {segment.lane.label}</span>
454
+ <span className="tita-attrBandCount">
455
+ {shown}
456
+ {segment.total !== shown && <span className="tita-attrBandCountDim">/{segment.total}</span>}
457
+ </span>
458
+ <span className="tita-attrBandHint">{picks.length > 0 ? '' : readout.text}</span>
459
+ </button>
460
+ {picks.length > 0 && onPickSymptom && (
461
+ <span className="tita-attrBandPicks">
462
+ <span className="tita-attrBandPicksLabel">换</span>
463
+ {picks.slice(0, limit).map(node => <button
464
+ key={node.id}
465
+ type="button"
466
+ className="tita-attrBandPick"
467
+ onClick={() => onPickSymptom(node.id)}
468
+ title={`看 ${node.id} ${node.title} 的动线`}
469
+ >{node.id}</button>)}
470
+ {picks.length > limit && <button
471
+ type="button"
472
+ className="tita-attrBandPick"
473
+ // 多出来的收成一枚 `+N`:段头是入口不是清单,但**不能假装只有这几个**。
474
+ title={`还有 ${picks.length - limit} 个现象:${picks.slice(limit).map(node => `${node.id} ${node.title}`).join('、')}`}
475
+ onClick={() => onPickSymptom(picks[limit]!.id)}
476
+ >+{picks.length - limit}</button>}
477
+ </span>
478
+ )}
479
+ </div>
480
+ })}
481
+ </div>
482
+ }
483
+
484
+ /**
485
+ * 段头最多放几个现象入口,超出的收成 `+N`。
486
+ *
487
+ * 380px 的段头里,「① 问题现象 1/5」已经占掉一半,再排五个 `candidate-1s1w50u` 这种
488
+ * 长度的 id 就会换行、把段头顶出带子。所以限个数,多出来的进 `+N`(悬停能看到全部)。
489
+ * 段头是**入口**不是清单 —— 真要看全部现象,左栏的①组里有。
490
+ */
491
+ const PICK_LIMIT: Record<StageMode, number> = { panel: 2, fullscreen: 4 }
492
+
493
+ /** 段的三态:空(没数据)/ 收起(有数据但不画)/ 被切面关掉。三者互斥,见 `laneReadoutOf`。 */
494
+ function laneStateOf(segment: LaneSegment, lanesOff: ReadonlySet<LaneId>): 'empty' | 'off' | 'collapsed' | 'open' {
495
+ if (segment.total === 0) return 'empty'
496
+ if (lanesOff.has(segment.lane.id)) return 'off'
497
+ return segment.collapsed ? 'collapsed' : 'open'
104
498
  }
499
+
500
+ /**
501
+ * 段头的读数。**三种「这一段的节点没画出来」必须分开说**,因为含义完全不同:
502
+ *
503
+ * | 状态 | 含义 | 段头写什么 |
504
+ * | --- | --- | --- |
505
+ * | `empty`(total = 0) | 这类数据一条都没有 | 段的 `note`:「还没有查询依据:检查点没写 assets」 |
506
+ * | `collapsed` | 有数据,是**你**选择暂时不看 | 「已收起 · N 个 · 点这里展开」 |
507
+ * | `lanesOff` | 有数据,是**切面**把它关了 | 「已关闭 · N 个」 |
508
+ *
509
+ * 上一版只有 `shown === 0 ? note : orderHint`,于是用户主动收起④段时,段头会写
510
+ * 「还没有查询依据」—— 那是**在撒谎**,而且恰好抹掉了 §7.7 说最该显眼的那条读数
511
+ * (「哪一段还没建」)。
512
+ */
513
+ function laneReadoutOf(segment: LaneSegment, lanesOff: ReadonlySet<LaneId>): { text: string; action: boolean } {
514
+ if (segment.total === 0) return { text: segment.lane.note, action: false }
515
+ if (lanesOff.has(segment.lane.id)) return { text: `已关闭 · ${segment.total} 个(在左栏那一段的开关里打开)`, action: false }
516
+ if (segment.collapsed) {
517
+ const hidden = segment.total - segment.nodes.length
518
+ return { text: hidden > 0 ? `已收起 · ${hidden} 个 · 点这里展开` : '已收起 · 点这里展开', action: true }
519
+ }
520
+ return { text: segment.lane.orderHint, action: true }
521
+ }
522
+
523
+ /** 只更新已有节点(和新增节点)的位置,不重新下发整张图的数据。 */
524
+ function applyPlacements(graph: Graph, geometry: LaneGeometry, data: AttributionGraphData): void {
525
+ const updates = data.nodes
526
+ .map(node => ({ id: node.id, placement: geometry.nodes.get(node.id) }))
527
+ .filter(item => item.placement !== undefined)
528
+ .map(item => ({
529
+ id: item.id,
530
+ style: { x: item.placement!.x, y: item.placement!.y, size: [item.placement!.width, item.placement!.height] as [number, number] },
531
+ }))
532
+ // 与 `toGraphData` 同一处收口:样式是运行时按数据算出来的键值对,G6 的
533
+ // `NodeStyle` 是联合类型。断言只在这一行,别处不再需要。
534
+ graph.updateNodeData(updates as unknown as Parameters<Graph['updateNodeData']>[0])
535
+ void graph.draw()
536
+ }
537
+
538
+ /**
539
+ * 泳道带 + 段头。
540
+ *
541
+ * **叠一层 HTML 而不是写 G6 插件**,理由是段头要写中文与计数:G6 的 `text` 元素在
542
+ * 段位坐标里要自己做换行、对齐与省略,而这些正是浏览器排版最擅长的。代价是它必须与
543
+ * 画布严格对齐 —— 靠两条约束保证:
544
+ *
545
+ * 1. 相机锁在 `[0, 0, 1]`(见 `AttributionGraph` 顶部注释),所以世界坐标就是屏幕
546
+ * 像素,这里可以直接用 `geometry` 里的坐标;
547
+ * 2. 同一份 `LaneGeometry` **既喂画布也喂这里**,不存在两套坐标。
548
+ *
549
+ * 整层 `pointer-events-none`(§7.4 第 10 条):带子只负责读,命中一律落到画布上。
550
+ * 段开关在左栏,不在这里。
551
+ */