dsh-data-domain-knowledge 0.1.0-alpha.2 → 0.1.0-alpha.3
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.
- package/README.md +31 -7
- package/cordis.patch.yml +11 -5
- package/lib/client.js +5557 -3056
- package/lib/doc-page.js +7740 -0
- package/lib/types/convert/host.d.ts.map +1 -1
- package/lib/types/convert/host.js +36 -9
- package/lib/types/convert/host.js.map +1 -1
- package/lib/types/diagnosis/cards.d.ts +8 -7
- package/lib/types/diagnosis/cards.d.ts.map +1 -1
- package/lib/types/diagnosis/cards.js +119 -49
- package/lib/types/diagnosis/cards.js.map +1 -1
- package/lib/types/diagnosis/run.d.ts +51 -0
- package/lib/types/diagnosis/run.d.ts.map +1 -0
- package/lib/types/diagnosis/run.js +239 -0
- package/lib/types/diagnosis/run.js.map +1 -0
- package/lib/types/diagnosis/schema.d.ts +25 -2
- package/lib/types/diagnosis/schema.d.ts.map +1 -1
- package/lib/types/diagnosis/schema.js +64 -25
- package/lib/types/diagnosis/schema.js.map +1 -1
- package/lib/types/diagnosis/seed-source.d.ts +17 -2
- package/lib/types/diagnosis/seed-source.d.ts.map +1 -1
- package/lib/types/diagnosis/seed-source.js +18 -1
- package/lib/types/diagnosis/seed-source.js.map +1 -1
- package/lib/types/diagnosis/seed.d.ts +32 -42
- package/lib/types/diagnosis/seed.d.ts.map +1 -1
- package/lib/types/diagnosis/seed.js +187 -51
- package/lib/types/diagnosis/seed.js.map +1 -1
- package/lib/types/diagnosis/store.d.ts +114 -26
- package/lib/types/diagnosis/store.d.ts.map +1 -1
- package/lib/types/diagnosis/store.js +336 -167
- package/lib/types/diagnosis/store.js.map +1 -1
- package/lib/types/diagnosis/types.d.ts +168 -16
- package/lib/types/diagnosis/types.d.ts.map +1 -1
- package/lib/types/diagnosis/types.js +1 -1
- package/lib/types/diagnosis/types.js.map +1 -1
- package/lib/types/document-page.d.ts +39 -0
- package/lib/types/document-page.d.ts.map +1 -0
- package/lib/types/document-page.js +43 -0
- package/lib/types/document-page.js.map +1 -0
- package/lib/types/fs-atomic.d.ts +12 -0
- package/lib/types/fs-atomic.d.ts.map +1 -0
- package/lib/types/fs-atomic.js +42 -0
- package/lib/types/fs-atomic.js.map +1 -0
- package/lib/types/index.d.ts.map +1 -1
- package/lib/types/index.js +214 -24
- package/lib/types/index.js.map +1 -1
- package/lib/types/knowledge-bases.d.ts +10 -2
- package/lib/types/knowledge-bases.d.ts.map +1 -1
- package/lib/types/knowledge-bases.js +20 -18
- package/lib/types/knowledge-bases.js.map +1 -1
- package/lib/types/proposals.d.ts +166 -0
- package/lib/types/proposals.d.ts.map +1 -0
- package/lib/types/proposals.js +238 -0
- package/lib/types/proposals.js.map +1 -0
- package/lib/types/runtime.d.ts +199 -7
- package/lib/types/runtime.d.ts.map +1 -1
- package/lib/types/runtime.js +728 -145
- package/lib/types/runtime.js.map +1 -1
- package/lib/types/settings-schema.d.ts +2 -2
- package/lib/types/types.d.ts +61 -10
- package/lib/types/types.d.ts.map +1 -1
- package/lib/types/types.js +56 -8
- package/lib/types/types.js.map +1 -1
- package/lib/types/wiki-types.d.ts +8 -1
- package/lib/types/wiki-types.d.ts.map +1 -1
- package/lib/types/wiki-types.js +16 -25
- package/lib/types/wiki-types.js.map +1 -1
- package/package.json +4 -3
- package/preset/tita-data-agent/agent.cordis.yml +11 -5
- package/src/client/DirectoryPicker.tsx +12 -12
- package/src/client/InfoHint.tsx +21 -8
- package/src/client/IntentTab.tsx +20 -26
- package/src/client/KnowledgeBaseTab.tsx +190 -80
- package/src/client/KnowledgePanel.tsx +42 -9
- package/src/client/KnowledgeStepRail.tsx +3 -0
- package/src/client/NodeSettings.tsx +20 -22
- package/src/client/PreviewSections.tsx +87 -33
- package/src/client/ProposalReview.tsx +313 -0
- package/src/client/TypeDefinitionDrawer.tsx +72 -38
- package/src/client/WikiTypeEditor.tsx +193 -77
- package/src/client/arch/AgentArchitectureGraph.tsx +143 -14
- package/src/client/arch/NodeLogicPanel.tsx +6 -3
- package/src/client/arch/model.ts +164 -26
- package/src/client/architecture.css +29 -9
- package/src/client/architecture.tsx +54 -29
- package/src/client/attribution/AttributionGraph.tsx +481 -34
- package/src/client/attribution/AttributionTab.tsx +507 -106
- package/src/client/attribution/CaseView.tsx +9 -14
- package/src/client/attribution/GraphStage.tsx +25 -18
- package/src/client/attribution/RunView.tsx +27 -9
- package/src/client/attribution/StageChrome.tsx +296 -140
- package/src/client/attribution/graph-options.ts +611 -47
- package/src/client/attribution/model.ts +559 -30
- package/src/client/convert/ConvertPanel.tsx +6 -1
- package/src/client/doc-page.css +134 -0
- package/src/client/doc-page.tsx +263 -0
- package/src/client/panel-state.ts +105 -0
- package/src/client/request.ts +36 -0
- package/src/client/styles.css +317 -43
- package/src/client/useFitScale.ts +15 -2
- package/src/client/wiki-type-groups.ts +86 -0
- package/src/convert/host.ts +41 -7
- package/src/diagnosis/cards.ts +120 -50
- package/src/diagnosis/run.ts +245 -0
- package/src/diagnosis/schema.ts +67 -26
- package/src/diagnosis/seed-source.ts +28 -3
- package/src/diagnosis/seed.ts +187 -85
- package/src/diagnosis/store.ts +358 -174
- package/src/diagnosis/types.ts +181 -16
- package/src/document-page.ts +51 -0
- package/src/fs-atomic.ts +42 -0
- package/src/index.ts +209 -22
- package/src/knowledge-bases.ts +20 -18
- package/src/proposals.ts +337 -0
- package/src/runtime.ts +736 -135
- package/src/types.ts +113 -19
- package/src/wiki-types.ts +17 -27
|
@@ -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
|
|
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
|
-
/** `
|
|
24
|
+
/** `line` 动线(默认,五段泳道);`force` 关系(浮动节点)。 */
|
|
19
25
|
layout: GraphLayout
|
|
20
|
-
/** 视口参数档位:面板内 /
|
|
26
|
+
/** 视口参数档位:面板内 / 全屏。决定缩放区间、留白与段位尺寸。 */
|
|
21
27
|
mode: StageMode
|
|
28
|
+
/** 选中节点:它的那条链被点亮,其余降到 0.35(§7.4 第 9 条)。 */
|
|
22
29
|
selectedId?: string
|
|
23
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
36
|
-
* `
|
|
37
|
-
*
|
|
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({
|
|
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,
|
|
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(
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
graph.setData(toGraphData(
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
95
|
-
//
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
+
*/
|