node-resource-utils 0.0.0-stage → 1.1.8
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/LICENSE +21 -0
- package/README.md +35 -2
- package/lib/sync.js +121 -0
- package/package.json +14 -4
- package/resources/.cache/ref.b64 +1 -0
- package/resources/README.md +97 -0
- package/resources/_.env +6 -0
- package/resources/_.env.example +25 -0
- package/resources/_.gitignore +18 -0
- package/resources/_.lintstagedrc.json +9 -0
- package/resources/_.npmrc +5 -0
- package/resources/_.prettierrc +7 -0
- package/resources/commitlint.config.mjs +1 -0
- package/resources/content/docs/README.md +63 -0
- package/resources/content/docs/faq.md +107 -0
- package/resources/content/docs/features/_meta.json +5 -0
- package/resources/content/docs/features/ai.md +71 -0
- package/resources/content/docs/features/appearance.md +47 -0
- package/resources/content/docs/features/feedback.md +79 -0
- package/resources/content/docs/features/files.md +74 -0
- package/resources/content/docs/features/graph.md +66 -0
- package/resources/content/docs/features/inbox.md +61 -0
- package/resources/content/docs/guide/_meta.json +5 -0
- package/resources/content/docs/guide/account.md +76 -0
- package/resources/content/docs/guide/data.md +87 -0
- package/resources/content/docs/guide/navigation.md +81 -0
- package/resources/content/docs/guide/permissions.md +67 -0
- package/resources/content/docs/guide/system.md +92 -0
- package/resources/content/docs/quick-start.md +70 -0
- package/resources/content/docs/shortcuts.md +69 -0
- package/resources/eslint.config.mjs +17 -0
- package/resources/next-env.d.ts +6 -0
- package/resources/next.config.ts +13 -0
- package/resources/postcss.config.mjs +7 -0
- package/resources/prisma/org.config.ts +98 -0
- package/resources/prisma/schema.prisma +336 -0
- package/resources/prisma/seed.ts +721 -0
- package/resources/prisma/seedSafe.ts +347 -0
- package/resources/prisma.config.ts +14 -0
- package/resources/scripts/check-config.mjs +773 -0
- package/resources/scripts/lib/schema-graph.mjs +410 -0
- package/resources/scripts/patch-next-trace.mjs +86 -0
- package/resources/scripts/suggest-relations.mjs +105 -0
- package/resources/src/app/(admin)/ai/page.tsx +469 -0
- package/resources/src/app/(admin)/dashboard/page.tsx +260 -0
- package/resources/src/app/(admin)/demo/employees/page.tsx +508 -0
- package/resources/src/app/(admin)/demo/files/page.tsx +451 -0
- package/resources/src/app/(admin)/demo/loading/page.tsx +513 -0
- package/resources/src/app/(admin)/error.tsx +96 -0
- package/resources/src/app/(admin)/feedback/page.tsx +573 -0
- package/resources/src/app/(admin)/graph/page.tsx +1224 -0
- package/resources/src/app/(admin)/inbox/page.tsx +31 -0
- package/resources/src/app/(admin)/layout.tsx +58 -0
- package/resources/src/app/(admin)/loading.tsx +31 -0
- package/resources/src/app/(admin)/system/depts/page.tsx +415 -0
- package/resources/src/app/(admin)/system/logs/page.tsx +215 -0
- package/resources/src/app/(admin)/system/menus/page.tsx +403 -0
- package/resources/src/app/(admin)/system/roles/page.tsx +433 -0
- package/resources/src/app/(admin)/system/users/page.tsx +620 -0
- package/resources/src/app/api/account-recovery/route.ts +132 -0
- package/resources/src/app/api/ai/act/route.ts +70 -0
- package/resources/src/app/api/ai/ask/route.ts +82 -0
- package/resources/src/app/api/ai/chat/route.ts +404 -0
- package/resources/src/app/api/ai/report/route.ts +164 -0
- package/resources/src/app/api/auth/[...nextauth]/route.ts +3 -0
- package/resources/src/app/api/auth/change-password/route.ts +57 -0
- package/resources/src/app/api/auth/forgot/route.ts +98 -0
- package/resources/src/app/api/auth/register/route.ts +95 -0
- package/resources/src/app/api/auth/register-config/route.ts +51 -0
- package/resources/src/app/api/badges/route.ts +39 -0
- package/resources/src/app/api/depts/[id]/route.ts +145 -0
- package/resources/src/app/api/depts/route.ts +126 -0
- package/resources/src/app/api/docs/export/route.ts +87 -0
- package/resources/src/app/api/docs/parse/route.ts +57 -0
- package/resources/src/app/api/docs/search/route.ts +22 -0
- package/resources/src/app/api/employees/[id]/route.ts +147 -0
- package/resources/src/app/api/employees/export/route.ts +115 -0
- package/resources/src/app/api/employees/import/route.ts +205 -0
- package/resources/src/app/api/employees/route.ts +149 -0
- package/resources/src/app/api/export/route.ts +254 -0
- package/resources/src/app/api/favorites/route.ts +56 -0
- package/resources/src/app/api/feedback/ai/route.ts +31 -0
- package/resources/src/app/api/feedback/approve/route.ts +67 -0
- package/resources/src/app/api/feedback/report/route.ts +213 -0
- package/resources/src/app/api/feedback/route.ts +246 -0
- package/resources/src/app/api/files/[name]/route.ts +82 -0
- package/resources/src/app/api/graph/focus/route.ts +75 -0
- package/resources/src/app/api/graph/node/move/route.ts +48 -0
- package/resources/src/app/api/graph/node/route.ts +274 -0
- package/resources/src/app/api/graph/route.ts +272 -0
- package/resources/src/app/api/health/route.ts +11 -0
- package/resources/src/app/api/inbox/read/route.ts +36 -0
- package/resources/src/app/api/inbox/summary/route.ts +15 -0
- package/resources/src/app/api/logs/route.ts +82 -0
- package/resources/src/app/api/menus/[id]/route.ts +132 -0
- package/resources/src/app/api/menus/mine/route.ts +23 -0
- package/resources/src/app/api/menus/route.ts +89 -0
- package/resources/src/app/api/roles/[id]/menus/route.ts +73 -0
- package/resources/src/app/api/roles/[id]/route.ts +96 -0
- package/resources/src/app/api/roles/route.ts +114 -0
- package/resources/src/app/api/search/route.ts +22 -0
- package/resources/src/app/api/stats/route.ts +92 -0
- package/resources/src/app/api/upload/route.ts +38 -0
- package/resources/src/app/api/users/[id]/password/route.ts +55 -0
- package/resources/src/app/api/users/[id]/route.ts +145 -0
- package/resources/src/app/api/users/route.ts +153 -0
- package/resources/src/app/api/visits/route.ts +48 -0
- package/resources/src/app/change-password/ChangePasswordForm.tsx +174 -0
- package/resources/src/app/change-password/page.tsx +19 -0
- package/resources/src/app/docs/[[...slug]]/page.tsx +56 -0
- package/resources/src/app/docs/layout.tsx +34 -0
- package/resources/src/app/docs/loading.tsx +19 -0
- package/resources/src/app/docs/not-found.tsx +25 -0
- package/resources/src/app/forgot/page.tsx +392 -0
- package/resources/src/app/global-error.tsx +75 -0
- package/resources/src/app/globals.css +235 -0
- package/resources/src/app/layout.tsx +44 -0
- package/resources/src/app/login/page.tsx +267 -0
- package/resources/src/app/page.tsx +14 -0
- package/resources/src/app/register/page.tsx +237 -0
- package/resources/src/auth.ts +215 -0
- package/resources/src/components/AiPanel.tsx +970 -0
- package/resources/src/components/AuthContext.tsx +82 -0
- package/resources/src/components/DataTable.tsx +459 -0
- package/resources/src/components/EChart.tsx +140 -0
- package/resources/src/components/FeedbackAiModal.tsx +476 -0
- package/resources/src/components/FeedbackReportModal.tsx +479 -0
- package/resources/src/components/Markdown.tsx +532 -0
- package/resources/src/components/OntologyGraph.tsx +1196 -0
- package/resources/src/components/Providers.tsx +99 -0
- package/resources/src/components/VisitTracker.tsx +89 -0
- package/resources/src/components/admin/AdminShell.tsx +749 -0
- package/resources/src/components/admin/CountBadge.tsx +67 -0
- package/resources/src/components/admin/GlobalSearch.tsx +509 -0
- package/resources/src/components/admin/InboxBell.tsx +78 -0
- package/resources/src/components/admin/VisitStats.tsx +145 -0
- package/resources/src/components/admin/menu-icons.tsx +56 -0
- package/resources/src/components/ai/AiInputForm.tsx +351 -0
- package/resources/src/components/appearance/AppearanceDrawer.tsx +268 -0
- package/resources/src/components/appearance/AppearanceProvider.tsx +160 -0
- package/resources/src/components/docs/DocArticle.tsx +106 -0
- package/resources/src/components/docs/DocToc.tsx +100 -0
- package/resources/src/components/docs/DocsChrome.tsx +259 -0
- package/resources/src/components/docs/DocsHome.tsx +209 -0
- package/resources/src/components/docs/DocsSearch.tsx +365 -0
- package/resources/src/components/docs/docs.css +1458 -0
- package/resources/src/components/feedback/AsyncButton.tsx +108 -0
- package/resources/src/components/feedback/LoadingOverlay.tsx +125 -0
- package/resources/src/components/feedback/PressableCard.tsx +92 -0
- package/resources/src/components/feedback/README.md +97 -0
- package/resources/src/components/feedback/RouteProgress.tsx +108 -0
- package/resources/src/components/feedback/Skeleton.tsx +249 -0
- package/resources/src/components/feedback/StatCard.tsx +70 -0
- package/resources/src/components/feedback/TaskHint.tsx +153 -0
- package/resources/src/components/feedback/index.ts +93 -0
- package/resources/src/components/feedback/motion.css +204 -0
- package/resources/src/components/feedback/motion.ts +63 -0
- package/resources/src/components/feedback/useCountUp.ts +110 -0
- package/resources/src/components/feedback/usePending.ts +209 -0
- package/resources/src/components/feedback/useRowFlash.ts +40 -0
- package/resources/src/components/inbox/InboxView.tsx +409 -0
- package/resources/src/components/ui/button.tsx +35 -0
- package/resources/src/components/ui/card.tsx +37 -0
- package/resources/src/components/ui/input.tsx +19 -0
- package/resources/src/lib/ai.ts +310 -0
- package/resources/src/lib/aiContext.ts +357 -0
- package/resources/src/lib/aiForm.test.ts +191 -0
- package/resources/src/lib/aiForm.ts +532 -0
- package/resources/src/lib/aiFormTypes.ts +83 -0
- package/resources/src/lib/aiToken.ts +72 -0
- package/resources/src/lib/aiTools.ts +823 -0
- package/resources/src/lib/api.ts +160 -0
- package/resources/src/lib/appInfo.ts +128 -0
- package/resources/src/lib/appearance.ts +643 -0
- package/resources/src/lib/badge-count.ts +146 -0
- package/resources/src/lib/badges.test.ts +58 -0
- package/resources/src/lib/badges.ts +79 -0
- package/resources/src/lib/brand-mark.test.ts +87 -0
- package/resources/src/lib/brand-mark.ts +45 -0
- package/resources/src/lib/docs-shared.test.ts +148 -0
- package/resources/src/lib/docs-shared.ts +422 -0
- package/resources/src/lib/docs.ts +381 -0
- package/resources/src/lib/err-text.ts +21 -0
- package/resources/src/lib/excel.ts +152 -0
- package/resources/src/lib/favorite-path.ts +60 -0
- package/resources/src/lib/favorites.ts +59 -0
- package/resources/src/lib/features.ts +69 -0
- package/resources/src/lib/feedback-access.ts +17 -0
- package/resources/src/lib/feedbackAi.ts +1621 -0
- package/resources/src/lib/formRules.ts +76 -0
- package/resources/src/lib/format.ts +52 -0
- package/resources/src/lib/graph-cascade.ts +304 -0
- package/resources/src/lib/graph-focus.ts +301 -0
- package/resources/src/lib/graph-link.ts +17 -0
- package/resources/src/lib/graph-node-write.ts +150 -0
- package/resources/src/lib/graph-schema.ts +475 -0
- package/resources/src/lib/graph-scope.test.ts +46 -0
- package/resources/src/lib/graph-scope.ts +28 -0
- package/resources/src/lib/http.ts +157 -0
- package/resources/src/lib/inbox-types.ts +57 -0
- package/resources/src/lib/inbox.ts +55 -0
- package/resources/src/lib/menu-service.ts +204 -0
- package/resources/src/lib/notifications.ts +113 -0
- package/resources/src/lib/office.ts +531 -0
- package/resources/src/lib/ontology.ts +277 -0
- package/resources/src/lib/openaiCompat.ts +257 -0
- package/resources/src/lib/pending.ts +98 -0
- package/resources/src/lib/prisma.ts +30 -0
- package/resources/src/lib/rbac.test.ts +106 -0
- package/resources/src/lib/rbac.ts +185 -0
- package/resources/src/lib/search-data.ts +158 -0
- package/resources/src/lib/security-questions.ts +20 -0
- package/resources/src/lib/skin-theme.ts +155 -0
- package/resources/src/lib/system-config.ts +55 -0
- package/resources/src/lib/theme.ts +96 -0
- package/resources/src/lib/todos/feedback.ts +86 -0
- package/resources/src/lib/todos/index.ts +73 -0
- package/resources/src/lib/todos/types.ts +33 -0
- package/resources/src/lib/upload.ts +106 -0
- package/resources/src/lib/utils.test.ts +9 -0
- package/resources/src/lib/utils.ts +6 -0
- package/resources/src/lib/validation.test.ts +297 -0
- package/resources/src/lib/validation.ts +658 -0
- package/resources/src/lib/visit-stats.ts +109 -0
- package/resources/src/lib/visits.ts +111 -0
- package/resources/src/lib/zodField.ts +44 -0
- package/resources/tsconfig.json +41 -0
- package/resources/vitest.config.ts +16 -0
- package/resources/vitest.setup.ts +1 -0
|
@@ -0,0 +1,1196 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
|
4
|
+
import { Empty, Segmented, Space, Tooltip, Typography } from 'antd';
|
|
5
|
+
import type { ECharts } from 'echarts/core';
|
|
6
|
+
import EChart from '@/components/EChart';
|
|
7
|
+
import { fs, MILITARY } from '@/lib/theme';
|
|
8
|
+
import { useSkinInk } from '@/components/appearance/AppearanceProvider';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* 知识图谱渲染器(ECharts graph)。
|
|
12
|
+
*
|
|
13
|
+
* ## 设计要点(都是踩过坑之后的结论)
|
|
14
|
+
*
|
|
15
|
+
* 1. **默认分层布局**。按 ontology 里每个本体的 `layer` 从左到右分层排列,
|
|
16
|
+
* 同层节点按"最稀有邻居"聚簇后均匀分布 —— 同一父节点的子节点排在一起,
|
|
17
|
+
* 连线不互相穿插;一眼能看出"部门 → 员工 → 记录"的流向。
|
|
18
|
+
*
|
|
19
|
+
* 也提供力导布局(`segmented` 切换),但**不是** ECharts 自带的实时模拟,
|
|
20
|
+
* 而是"一次性算好坐标"的静态 FR(见 forceLayout 注释):结果可复现、
|
|
21
|
+
* 不会因为 hover/setOption 重新散开,且画布宽度变化时会重新铺一遍而不是被裁掉。
|
|
22
|
+
*
|
|
23
|
+
* 2. **浅色底 + 深色字**。深色填充配深色文字,节点上的字根本读不出来。
|
|
24
|
+
* 填充色由 ontology 的 `color` 决定(约定用浅色),文字固定深色。
|
|
25
|
+
*
|
|
26
|
+
* 3. **边标签默认不显示**。几十条边都挂标签会糊成一片。
|
|
27
|
+
* 只在 hover / 选中某个节点时,才显示与它相连的边标签 ——
|
|
28
|
+
* 靠 series.emphasis.focus='adjacency' 实现,**不通过改 option 实现**。
|
|
29
|
+
*
|
|
30
|
+
* ⚠️ 这里踩过一个坑:以前 hover 会 setState,进而重建 option、触发 setOption。
|
|
31
|
+
* 分层布局的节点有固定 x/y,看不出来;但**力导布局只要 setOption 就重跑模拟**,
|
|
32
|
+
* 鼠标一悬停整张图就重新散开,节点根本选不中。
|
|
33
|
+
* 现在 hover 完全不进 React 状态,选中高亮走 dispatchAction。
|
|
34
|
+
*
|
|
35
|
+
* 4. **节点可拖拽**,但那只是本地视图调整,不写库。
|
|
36
|
+
* 真正的增删改走右侧编辑面板(调用 /api/graph/node)。
|
|
37
|
+
*
|
|
38
|
+
* 5. **部门节点可折叠**。entity 为 'department' 的节点是"部门节点",
|
|
39
|
+
* 点击它可折叠/展开其下属(相邻节点)。折叠时下属节点及其相连的边不渲染。
|
|
40
|
+
*
|
|
41
|
+
* 6. **双击锁定关联**(2026-10)。单击只是"打开详情 + 鼠标态高亮"(鼠标一走就恢复),
|
|
42
|
+
* 双击才把「该节点 + 直接邻居」钉住,簇外压暗;双击空白处或按 Esc 解锁。
|
|
43
|
+
* 锁定是**持续状态**,只能落到数据样式上(重建 option),不能靠 emphasis ——
|
|
44
|
+
* ECharts 在 mouseout 时会自动 downplay,dispatchAction 保持不住。
|
|
45
|
+
* 坐标是预先算好的静态值,重建 option 不会让节点跑位。
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
export interface GNode {
|
|
49
|
+
id: string;
|
|
50
|
+
label: string;
|
|
51
|
+
entity: string;
|
|
52
|
+
color: string;
|
|
53
|
+
borderColor?: string;
|
|
54
|
+
subtitle: string;
|
|
55
|
+
link?: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GEdge {
|
|
59
|
+
source: string;
|
|
60
|
+
target: string;
|
|
61
|
+
label: string;
|
|
62
|
+
relation: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* 图表实例的最小接口(只声明用到的几个方法)。
|
|
67
|
+
* 直接 Pick echarts 自己的类型,别手写结构类型 ——
|
|
68
|
+
* dispatchAction 的参数是 Payload,手写 `Record<string, unknown>` 会因逆变规则报错。
|
|
69
|
+
*/
|
|
70
|
+
type ChartLike = Pick<ECharts, 'dispatchAction' | 'getZr'>;
|
|
71
|
+
|
|
72
|
+
export interface OntologyGraphProps {
|
|
73
|
+
nodes: GNode[];
|
|
74
|
+
edges: GEdge[];
|
|
75
|
+
entities: { id: string; label: string; color: string; layer?: number }[];
|
|
76
|
+
loading?: boolean;
|
|
77
|
+
height?: number;
|
|
78
|
+
onNodeClick?: (n: GNode) => void;
|
|
79
|
+
activeId?: string | null;
|
|
80
|
+
/** 已折叠的部门节点(受控)。不传则组件自己管 */
|
|
81
|
+
collapsed?: Set<string>;
|
|
82
|
+
/**
|
|
83
|
+
* 保存成功后闪一下的节点:`id` 是图内节点键(entity:dbId),
|
|
84
|
+
* `nonce` 让"同一个节点反复保存"也能重新触发(只比 id 的话第二次不会闪)。
|
|
85
|
+
*/
|
|
86
|
+
flash?: { id: string; nonce: number } | null;
|
|
87
|
+
/**
|
|
88
|
+
* 聚焦簇(业务列表「关联」跳转来的时候用):
|
|
89
|
+
* nodes —— 要高亮的节点键(entity:dbId)
|
|
90
|
+
* edges —— 要高亮的边(source/target 也是 entity:dbId,方向无所谓)
|
|
91
|
+
* 不在簇里的节点/边会被压暗,簇内的连线加粗 + 显示关系名。
|
|
92
|
+
*
|
|
93
|
+
* ★ 高亮走「重建 option」而不是 dispatchAction:
|
|
94
|
+
* hover/选中才必须走 dispatchAction(见文件头注释),
|
|
95
|
+
* 而聚焦是一个**持续状态**,要同时改一批节点+边的样式,ECharts 没有对应的 action。
|
|
96
|
+
* 坐标是预先算好的(forceLayout 也是静态计算),重建 option 不会让图跑位。
|
|
97
|
+
*/
|
|
98
|
+
focus?: { nodes: string[]; edges: { source: string; target: string }[] } | null;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 图谱坐标系统一用「隐藏笛卡尔坐标系」。
|
|
103
|
+
*
|
|
104
|
+
* ⚠️ 这里踩过一个坑,值得记住:
|
|
105
|
+
* ECharts graph 默认的 view 坐标系会把**节点中心的包围盒整体缩放铺满** series 区域
|
|
106
|
+
* (`echarts/lib/chart/graph/createView.js`:bbox.fromPoints → createViewCoordSysSimply)。
|
|
107
|
+
* 后果是最左/最右节点的中心被贴到画布边缘,符号一半在画布外 → 被裁掉半个字;
|
|
108
|
+
* 而且"把 x 往内收"(旧代码的 padX=170)会让包围盒更窄、缩放更大,**裁得更多**,
|
|
109
|
+
* 方向完全是反的。ECharts 6 也没有 `autoFit` 这个配置。
|
|
110
|
+
*
|
|
111
|
+
* 根治办法:挂一对隐藏的 value 轴(min/max 固定 0~100)+ grid,
|
|
112
|
+
* 坐标线性映射、绝不缩放;grid 的左右边距 60px / 上下 24px 大于节点半宽(52)/半高(18),
|
|
113
|
+
* 所以轴值取边缘也不会被裁。
|
|
114
|
+
*
|
|
115
|
+
* 下面两个布局函数都返回 **0~100 的轴坐标**(不是像素)。
|
|
116
|
+
*/
|
|
117
|
+
|
|
118
|
+
/** grid 边距(px):必须大于节点半宽 52 / 半高 18,否则边缘节点仍会被裁 */
|
|
119
|
+
const GRID = { left: 60, right: 60, top: 24, bottom: 24 };
|
|
120
|
+
|
|
121
|
+
/** 节点尺寸(与 series.symbolSize 保持一致) */
|
|
122
|
+
const NODE_W = 104;
|
|
123
|
+
const NODE_H = 36;
|
|
124
|
+
/** 去重叠时的安全间距(含 6px 呼吸) */
|
|
125
|
+
const SEP_W = NODE_W + 12;
|
|
126
|
+
const SEP_H = NODE_H + 12;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* 像素 → 轴坐标(0~100)。
|
|
130
|
+
* ⚠️ 必须减去 grid 原点:grid 从 (GRID.left, GRID.top) 才开始,
|
|
131
|
+
* 不减的话整体会右移 60/画布宽 ≈ 6%,最右列直接越过轴上限 100 被裁掉(踩过)。
|
|
132
|
+
*/
|
|
133
|
+
function toAxis(px: number, py: number, gw: number, gh: number) {
|
|
134
|
+
return {
|
|
135
|
+
x: ((px - GRID.left) / gw) * 100,
|
|
136
|
+
y: ((py - GRID.top) / gh) * 100,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* 分层布局:按 entity 的 layer 决定 x(列),同层内按"锚点聚簇"排序再分配 y(行)。
|
|
142
|
+
*
|
|
143
|
+
* 两个硬约束:
|
|
144
|
+
* 1. 行距至少 40px:一列 20 个节点时 560px 高度只够 25px/行,节点高 36px 必然叠在一起。
|
|
145
|
+
* 所以高度不够时由调用方把图表撑高(见 maxColCount / chartHeight)。
|
|
146
|
+
* 2. x 从"左边距 + 节点半宽"起算,保证最左节点的左边缘不出画布。
|
|
147
|
+
*
|
|
148
|
+
* ## 竖排顺序:最稀有邻居做锚点(降交叉的关键)
|
|
149
|
+
*
|
|
150
|
+
* 早期版本同层内直接按 label 排序,看起来"整齐",连线却是一张网:
|
|
151
|
+
* 请销假图里 18 条请假记录按标题字典序散在右列,跟中列 8 个人的连线全部互相穿插。
|
|
152
|
+
*
|
|
153
|
+
* 第二版用标准 barycenter(邻居序号的平均值)仍不够 —— 真实业务图里几乎总有**枢纽**:
|
|
154
|
+
* 审批人一人连着十几条记录,无权重的平均值会把所有记录都拽到"枢纽们的平均位置",
|
|
155
|
+
* 归属关系的排序信号被完全淹没。这与业务无关,任何"少量审批人 + 大量单据"的图都会这样。
|
|
156
|
+
*
|
|
157
|
+
* 所以现在做两处通用修正(只看图结构,不看任何业务字段):
|
|
158
|
+
* 1. **锚点 = 最稀有的邻居**(度数最小者)。归属边在业务图里几乎总是"一对一/一对少",
|
|
159
|
+
* 而审批、共享、关联这类边总是"一对多"——度数天然区分了两者,不需要任何业务语义。
|
|
160
|
+
* 同一父节点的子节点拿到同一个锚点序号,排序后自动聚成一簇,簇与簇不穿插。
|
|
161
|
+
* 2. **簇内按 label 排**,同样数据每次结果一模一样(截图、演示稳定)。
|
|
162
|
+
*
|
|
163
|
+
* 左→右一遍扫完即可(锚点都在左边的层,左边定了右边就定了)。
|
|
164
|
+
* 无邻居的节点保持原位(用自己当前序号当 bary 值),初值是 label 序。
|
|
165
|
+
*/
|
|
166
|
+
function layoutNodes(
|
|
167
|
+
nodes: GNode[],
|
|
168
|
+
entities: { id: string; layer?: number }[],
|
|
169
|
+
width: number,
|
|
170
|
+
height: number,
|
|
171
|
+
edges: GEdge[] = []
|
|
172
|
+
) {
|
|
173
|
+
const layerOf = new Map<string, number>();
|
|
174
|
+
entities.forEach((e, i) => layerOf.set(e.id, e.layer ?? i));
|
|
175
|
+
|
|
176
|
+
const byLayer = new Map<number, GNode[]>();
|
|
177
|
+
for (const n of nodes) {
|
|
178
|
+
const l = layerOf.get(n.entity) ?? 99;
|
|
179
|
+
if (!byLayer.has(l)) byLayer.set(l, []);
|
|
180
|
+
byLayer.get(l)!.push(n);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const layers = [...byLayer.keys()].sort((a, b) => a - b);
|
|
184
|
+
const pos = new Map<string, { x: number; y: number }>();
|
|
185
|
+
|
|
186
|
+
// ── 层内顺序:label 初值 → 重心微调 ──────────────────────
|
|
187
|
+
const labelOf = new Map(nodes.map((n) => [n.id, n.label]));
|
|
188
|
+
const idxOf = new Map(nodes.map((n, i) => [n.id, i]));
|
|
189
|
+
// 无向邻接(和渲染层一致:同一条关系只算一次)
|
|
190
|
+
const nbrs: number[][] = nodes.map(() => []);
|
|
191
|
+
for (const e of edges) {
|
|
192
|
+
const a = idxOf.get(e.source);
|
|
193
|
+
const b = idxOf.get(e.target);
|
|
194
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
195
|
+
if (nbrs[a].includes(b)) continue;
|
|
196
|
+
nbrs[a].push(b);
|
|
197
|
+
nbrs[b].push(a);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// order[li] = 第 li 层的节点 id(层内有序);rank = 当前列内序号
|
|
201
|
+
const rank = new Map<string, number>();
|
|
202
|
+
const order: string[][] = layers.map((l) => {
|
|
203
|
+
const ids = byLayer
|
|
204
|
+
.get(l)!
|
|
205
|
+
.map((n) => n.id)
|
|
206
|
+
.sort((a, b) => labelOf.get(a)!.localeCompare(labelOf.get(b)!, 'zh'));
|
|
207
|
+
ids.forEach((id, i) => rank.set(id, i));
|
|
208
|
+
return ids;
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
/** 节点 id 的锚点序号 = refLayer 里**度数最小**的邻居的序号;没有这种邻居就保持原序号 */
|
|
212
|
+
const baryOf = (id: string, refLayer: number) => {
|
|
213
|
+
const i = idxOf.get(id)!;
|
|
214
|
+
let best = -1;
|
|
215
|
+
for (const j of nbrs[i]) {
|
|
216
|
+
const nl = layerOf.get(nodes[j].entity) ?? 99;
|
|
217
|
+
if (nl !== refLayer) continue;
|
|
218
|
+
if (best < 0) {
|
|
219
|
+
best = j;
|
|
220
|
+
continue;
|
|
221
|
+
}
|
|
222
|
+
// 比度数 → 度数相同比 label(保证确定性,不依赖当前 rank 来回摆)
|
|
223
|
+
const d = nbrs[j].length - nbrs[best].length;
|
|
224
|
+
if (
|
|
225
|
+
d < 0 ||
|
|
226
|
+
(d === 0 &&
|
|
227
|
+
labelOf.get(nodes[j].id)!.localeCompare(labelOf.get(nodes[best].id)!, 'zh') < 0)
|
|
228
|
+
) {
|
|
229
|
+
best = j;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return best < 0 ? rank.get(id)! : rank.get(nodes[best].id)!;
|
|
233
|
+
};
|
|
234
|
+
/**
|
|
235
|
+
* 把第 li 层按"锚点在 refLi 层的序号"重排(并列时按 label)。
|
|
236
|
+
* 锚点相同 = 同一父节点的子节点 → 排完自动聚成一簇,簇内不交叉。
|
|
237
|
+
*/
|
|
238
|
+
const applyBary = (li: number, refLi: number) => {
|
|
239
|
+
const ids = order[li];
|
|
240
|
+
const refLayer = layers[refLi];
|
|
241
|
+
const keys = new Map(ids.map((id) => [id, baryOf(id, refLayer)]));
|
|
242
|
+
ids.sort((a, b) => {
|
|
243
|
+
const d = keys.get(a)! - keys.get(b)!;
|
|
244
|
+
return d !== 0 ? d : labelOf.get(a)!.localeCompare(labelOf.get(b)!, 'zh');
|
|
245
|
+
});
|
|
246
|
+
ids.forEach((id, i) => rank.set(id, i));
|
|
247
|
+
};
|
|
248
|
+
/**
|
|
249
|
+
* 只做左→右一遍:第 1 层定完 → 第 2 层跟着排 → ……
|
|
250
|
+
* (一遍就够:锚点全在左边的层,左边定了右边就定了。)
|
|
251
|
+
*
|
|
252
|
+
* 为什么不加右→左回扫:回扫会按"最稀有的子节点"重排父层 ——
|
|
253
|
+
* 子节点度数都差不多(并列比 label),父层的聚簇反而被打散。
|
|
254
|
+
*/
|
|
255
|
+
for (let li = 1; li < order.length; li += 1) applyBary(li, li - 1);
|
|
256
|
+
|
|
257
|
+
const gw = Math.max(width - GRID.left - GRID.right, 200);
|
|
258
|
+
const gh = Math.max(height - GRID.top - GRID.bottom, 120);
|
|
259
|
+
|
|
260
|
+
// 列间距:给左右各留一个节点半宽,最左/最右节点都完整可见
|
|
261
|
+
const usableW = Math.max(gw - NODE_W, 1);
|
|
262
|
+
const stepX = usableW / Math.max(layers.length - 1, 1);
|
|
263
|
+
|
|
264
|
+
layers.forEach((layer, li) => {
|
|
265
|
+
const ids = order[li];
|
|
266
|
+
// 只有一列时居中(否则单节点/单层视图会贴在最左边,看着像被裁了)
|
|
267
|
+
const x = layers.length === 1 ? GRID.left + gw / 2 : GRID.left + NODE_W / 2 + li * stepX;
|
|
268
|
+
// 行距 = 可用高度 / 本列节点数;调用方保证 gh ≥ 节点数 × 40
|
|
269
|
+
const stepY = gh / Math.max(ids.length, 1);
|
|
270
|
+
ids.forEach((id, i) => {
|
|
271
|
+
pos.set(id, toAxis(x, GRID.top + stepY * (i + 0.5), gw, gh));
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
return pos;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* 力导布局调参(按真实图谱反复调出来的,不要凭感觉改)
|
|
279
|
+
*/
|
|
280
|
+
const FORCE = {
|
|
281
|
+
/** 理想间距系数:k = 系数 × √(画布面积 / 节点数),同时是相邻节点的自然间距 */
|
|
282
|
+
spacing: 1.2,
|
|
283
|
+
/** 迭代次数:300 次后位移已收敛到 1px 以内 */
|
|
284
|
+
iter: 300,
|
|
285
|
+
/** 向心力(力/px):太小会让互不相连的几团越飘越远,太大则全挤到中心 */
|
|
286
|
+
gravity: 0.06,
|
|
287
|
+
/**
|
|
288
|
+
* 斥力作用距离(单位:k)。超过这个距离斥力迅速衰减 ——
|
|
289
|
+
* 这一步是**整张图不炸开的关键**(详见函数注释里的"爆炸"说明)。
|
|
290
|
+
*/
|
|
291
|
+
reach: 3.2,
|
|
292
|
+
};
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* 力导布局:一次性算好坐标,之后就是静态的。
|
|
296
|
+
*
|
|
297
|
+
* 为什么不用 ECharts 自带的 `layout: 'force'`:
|
|
298
|
+
* 它是**实时模拟**,只要 setOption(切换模式、折叠、甚至 hover 改 option)
|
|
299
|
+
* 就会从随机初始位置重跑,节点会突然散开,鼠标根本追不上,更别说点选。
|
|
300
|
+
*
|
|
301
|
+
* ## 旧版为什么"乱"(以及这一版怎么改的)
|
|
302
|
+
*
|
|
303
|
+
* 旧版是"把节点撒在一个圆上 + 各向同性弹簧",画出来是一团**竖着的毛球**:
|
|
304
|
+
* 1. 斥力 `R/d²` 衰减太快,只能在很近的距离起作用 → 整团被向心力压成一个点;
|
|
305
|
+
* 2. 弹簧原长写死 110px,比节点宽度 104px 只多 6px —— 相连的两个节点几乎贴在一起,
|
|
306
|
+
* 连线短得只剩一个线头,剩下的长线全部横穿整张图;
|
|
307
|
+
* 3. 向心力是常数,枢纽和叶子被同等拉向中心,中心区域密不透风。
|
|
308
|
+
*
|
|
309
|
+
* 第一次改成标准 FR(k²/d + d²/k)后反而更糟:FR 的斥力是**长程**的,
|
|
310
|
+
* 而真实图谱里常有互不相连的部分(角色节点一条边都没有,三个部门之间也没关系),
|
|
311
|
+
* 它们只有斥力、没有弹簧往回拉,会被一路推到几千像素外;
|
|
312
|
+
* 随后"等比缩放回画布"把整张图压十几倍,节点尺寸却是固定的 104×36,
|
|
313
|
+
* 缩完直接叠成一团,再去重叠就推成**一条条竖排的链**(实测坐标跑到 3768,2015)。
|
|
314
|
+
*
|
|
315
|
+
* 所以现在四步(全部确定性,同样数据每次画出来一模一样,截图/演示稳定):
|
|
316
|
+
* 1. **确定性播种**:从度数最高的节点做 BFS,按层放同心椭圆 —— 枢纽天然在中心、
|
|
317
|
+
* 叶子在外圈,这正是力导收敛后最理想的结构,比随机撒点少绕很多弯路。
|
|
318
|
+
* 2. **短程斥力**:FR 的 k²/d 再乘 1/(1+(d/reach·k)²) —— 超过 3 个 k 就迅速衰减,
|
|
319
|
+
* 向心力重新占主导,整张图的尺度稳定在"塞得进画布"的量级。
|
|
320
|
+
* 3. **度数自适应边长**:两端度数越高、理想边长越长 —— 枢纽周围留白,
|
|
321
|
+
* 不再"十几个节点挤在中心、线从中心射向四周"。
|
|
322
|
+
* 4. **非等比回填 + 矩形去重叠**:x/y 各自缩放到贴边(差不超过 1.7 倍),
|
|
323
|
+
* 图形自然长成画布的形状;最后按 104×36 的矩形把残余重叠推开(矩形而不是圆)。
|
|
324
|
+
*/
|
|
325
|
+
function forceLayout(nodes: GNode[], edges: GEdge[], width: number, height: number) {
|
|
326
|
+
const n = nodes.length;
|
|
327
|
+
const out = new Map<string, { x: number; y: number }>();
|
|
328
|
+
if (!n) return out;
|
|
329
|
+
|
|
330
|
+
const gw = Math.max(width - GRID.left - GRID.right, 200);
|
|
331
|
+
const gh = Math.max(height - GRID.top - GRID.bottom, 120);
|
|
332
|
+
// 节点中心的可落点范围:先扣掉半个节点,边缘节点才不会被裁
|
|
333
|
+
const minX = GRID.left + NODE_W / 2;
|
|
334
|
+
const maxX = GRID.left + gw - NODE_W / 2;
|
|
335
|
+
const minY = GRID.top + NODE_H / 2;
|
|
336
|
+
const maxY = GRID.top + gh - NODE_H / 2;
|
|
337
|
+
const cx = (minX + maxX) / 2;
|
|
338
|
+
const cy = (minY + maxY) / 2;
|
|
339
|
+
|
|
340
|
+
// ── 邻接表(无向去重,和渲染层一样:同一对节点只算一条弹簧) ──
|
|
341
|
+
const idx = new Map(nodes.map((x, i) => [x.id, i]));
|
|
342
|
+
const adj: number[][] = nodes.map(() => []);
|
|
343
|
+
const links: [number, number][] = [];
|
|
344
|
+
for (const e of edges) {
|
|
345
|
+
const a = idx.get(e.source);
|
|
346
|
+
const b = idx.get(e.target);
|
|
347
|
+
if (a === undefined || b === undefined || a === b) continue;
|
|
348
|
+
if (adj[a].includes(b)) continue;
|
|
349
|
+
adj[a].push(b);
|
|
350
|
+
adj[b].push(a);
|
|
351
|
+
links.push([a, b]);
|
|
352
|
+
}
|
|
353
|
+
const deg = adj.map((l) => l.length);
|
|
354
|
+
|
|
355
|
+
// ── 1) 确定性播种:BFS 分层 → 同心椭圆 ──────────────────────
|
|
356
|
+
const root = deg.reduce((best, d, i) => (d > deg[best] ? i : best), 0);
|
|
357
|
+
const level = new Array<number>(n).fill(-1);
|
|
358
|
+
level[root] = 0;
|
|
359
|
+
let frontier = [root];
|
|
360
|
+
while (frontier.length) {
|
|
361
|
+
const next: number[] = [];
|
|
362
|
+
for (const u of frontier) {
|
|
363
|
+
for (const v of adj[u]) {
|
|
364
|
+
if (level[v] < 0) {
|
|
365
|
+
level[v] = level[u] + 1;
|
|
366
|
+
next.push(v);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
frontier = next;
|
|
371
|
+
}
|
|
372
|
+
let maxLevel = 0;
|
|
373
|
+
for (const l of level) if (l > maxLevel) maxLevel = l;
|
|
374
|
+
// 孤立节点(没有边)挂到最外圈,否则会和中心节点抢位置
|
|
375
|
+
for (let i = 0; i < n; i += 1) if (level[i] < 0) level[i] = maxLevel + 1;
|
|
376
|
+
|
|
377
|
+
const outer = Math.max(maxLevel, 1);
|
|
378
|
+
const byLevel = new Map<number, number[]>();
|
|
379
|
+
for (let i = 0; i < n; i += 1) {
|
|
380
|
+
if (!byLevel.has(level[i])) byLevel.set(level[i], []);
|
|
381
|
+
byLevel.get(level[i])!.push(i);
|
|
382
|
+
}
|
|
383
|
+
const rx = (maxX - minX) * 0.42;
|
|
384
|
+
const ry = (maxY - minY) * 0.42;
|
|
385
|
+
const seedX = new Array<number>(n).fill(cx);
|
|
386
|
+
const seedY = new Array<number>(n).fill(cy);
|
|
387
|
+
byLevel.forEach((list, lv) => {
|
|
388
|
+
const r = Math.max(lv / outer, lv === 0 ? 0 : 0.4);
|
|
389
|
+
list.forEach((i, k) => {
|
|
390
|
+
// 每层角度错开 0.8 弧度,避免上下两层的节点排成一条竖线
|
|
391
|
+
const a = (k / list.length) * Math.PI * 2 + lv * 0.8 - Math.PI / 2;
|
|
392
|
+
seedX[i] = cx + Math.cos(a) * rx * r;
|
|
393
|
+
seedY[i] = cy + Math.sin(a) * ry * r;
|
|
394
|
+
});
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
// ── 2) FR 迭代(各向异性斥力 + 度数自适应边长的弹簧) ───────
|
|
398
|
+
const k = Math.max(Math.min(Math.sqrt((gw * gh) / n) * FORCE.spacing, 240), NODE_W + 30);
|
|
399
|
+
const k2 = k * k;
|
|
400
|
+
const px = seedX.map((x, i) => ({ x, y: seedY[i] }));
|
|
401
|
+
const dispX = new Array<number>(n).fill(0);
|
|
402
|
+
const dispY = new Array<number>(n).fill(0);
|
|
403
|
+
|
|
404
|
+
for (let iter = 0; iter < FORCE.iter; iter += 1) {
|
|
405
|
+
dispX.fill(0);
|
|
406
|
+
dispY.fill(0);
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* 斥力:FR 的 k²/d,但**乘一个距离衰减因子**。
|
|
410
|
+
*
|
|
411
|
+
* ⚠️ 这里是最关键的一处(踩过一个大坑):纯 FR 的 k²/d 是长程力,
|
|
412
|
+
* 衰减极慢。真实图谱里常有互不相连的部分(本脚手架里"角色"节点就一条边都没有,
|
|
413
|
+
* 三个部门之间也没有关系),它们之间只有斥力、没有任何弹簧往回拉,
|
|
414
|
+
* 于是会被一路推出几千像素 —— 实测 26 个节点跑到 (3768, 2015)。
|
|
415
|
+
* 随后"等比缩放回画布"把整张图压缩十几倍,节点尺寸却是固定的 104×36,
|
|
416
|
+
* 缩完直接叠成一团,再由去重叠逻辑推成**一条条竖排的链**(正是截图里的鬼样子)。
|
|
417
|
+
*
|
|
418
|
+
* 所以斥力必须在 2~3 个 k 之外迅速衰减,让向心力重新占主导 ——
|
|
419
|
+
* 整张图的尺度因此稳定在"能塞进画布"的量级。
|
|
420
|
+
*/
|
|
421
|
+
const reach2 = (FORCE.reach * k) * (FORCE.reach * k);
|
|
422
|
+
for (let i = 0; i < n; i += 1) {
|
|
423
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
424
|
+
let dx = px[i].x - px[j].x;
|
|
425
|
+
let dy = px[i].y - px[j].y;
|
|
426
|
+
let d2 = dx * dx + dy * dy;
|
|
427
|
+
if (d2 < 1e-3) {
|
|
428
|
+
// 完全重合时按索引给一个确定方向;这里**不能用随机数**,
|
|
429
|
+
// 否则同一份数据每次布局结果都不一样(截图、演示全对不上)
|
|
430
|
+
dx = ((i * 37 + j * 17) % 7) - 3 || 0.5;
|
|
431
|
+
dy = ((i * 23 + j * 31) % 5) - 2 || 0.5;
|
|
432
|
+
d2 = dx * dx + dy * dy;
|
|
433
|
+
}
|
|
434
|
+
const d = Math.sqrt(d2);
|
|
435
|
+
const f = k2 / d / (1 + d2 / reach2);
|
|
436
|
+
const ux = (dx / d) * f;
|
|
437
|
+
const uy = (dy / d) * f;
|
|
438
|
+
dispX[i] += ux;
|
|
439
|
+
dispY[i] += uy;
|
|
440
|
+
dispX[j] -= ux;
|
|
441
|
+
dispY[j] -= uy;
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// 引力:FR 的 d²/k;两端度数越高原长越长,给枢纽留出呼吸空间
|
|
446
|
+
for (const [a, b] of links) {
|
|
447
|
+
const dx = px[b].x - px[a].x;
|
|
448
|
+
const dy = px[b].y - px[a].y;
|
|
449
|
+
const d = Math.sqrt(dx * dx + dy * dy) || 0.01;
|
|
450
|
+
const rest = k * (1 + 0.18 * Math.min(deg[a] + deg[b], 8));
|
|
451
|
+
const f = (d * d) / rest;
|
|
452
|
+
const ux = (dx / d) * f;
|
|
453
|
+
const uy = (dy / d) * f;
|
|
454
|
+
dispX[a] += ux;
|
|
455
|
+
dispY[a] += uy;
|
|
456
|
+
dispX[b] -= ux;
|
|
457
|
+
dispY[b] -= uy;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// 向心力 + 积分(步长随温度线性收敛,最后 1px 左右,避免来回抖)
|
|
461
|
+
const maxStep = 40 * (1 - iter / FORCE.iter) + 1;
|
|
462
|
+
for (let i = 0; i < n; i += 1) {
|
|
463
|
+
dispX[i] += (cx - px[i].x) * FORCE.gravity;
|
|
464
|
+
dispY[i] += (cy - px[i].y) * FORCE.gravity;
|
|
465
|
+
const len = Math.sqrt(dispX[i] * dispX[i] + dispY[i] * dispY[i]);
|
|
466
|
+
if (len > 0) {
|
|
467
|
+
const s = Math.min(len, maxStep) / len;
|
|
468
|
+
px[i].x += dispX[i] * s;
|
|
469
|
+
px[i].y += dispY[i] * s;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
// ── 3) 回填画布:分别缩放并居中 ───────────────────────────
|
|
475
|
+
let bx0 = Infinity;
|
|
476
|
+
let bx1 = -Infinity;
|
|
477
|
+
let by0 = Infinity;
|
|
478
|
+
let by1 = -Infinity;
|
|
479
|
+
for (const p of px) {
|
|
480
|
+
bx0 = Math.min(bx0, p.x);
|
|
481
|
+
bx1 = Math.max(bx1, p.x);
|
|
482
|
+
by0 = Math.min(by0, p.y);
|
|
483
|
+
by1 = Math.max(by1, p.y);
|
|
484
|
+
}
|
|
485
|
+
const spanX = Math.max(bx1 - bx0, 1);
|
|
486
|
+
const spanY = Math.max(by1 - by0, 1);
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 回填用**非等比**缩放,理由:
|
|
490
|
+
* 等比缩放会被"短的那一边"卡住 —— 方图放进 780×512 的宽画布,只能按高度铺满,
|
|
491
|
+
* 左右各留 160px 空白,看上去就是"没用满画布"。
|
|
492
|
+
* 两个方向各自缩放到贴边,图形自然长成画布的形状(宽屏 → 横向舒展)。
|
|
493
|
+
*
|
|
494
|
+
* 但要防止把"一条长链"横着摊成梳子,所以两边缩放比最多差 1.7 倍。
|
|
495
|
+
*/
|
|
496
|
+
const MAX_STRETCH = 1.7;
|
|
497
|
+
const fitX = (maxX - minX) / spanX;
|
|
498
|
+
const fitY = (maxY - minY) / spanY;
|
|
499
|
+
const base = Math.min(fitX, fitY, 1.35);
|
|
500
|
+
const scaleX = Math.min(fitX, base * MAX_STRETCH);
|
|
501
|
+
const scaleY = Math.min(fitY, base * MAX_STRETCH);
|
|
502
|
+
|
|
503
|
+
const pos = px.map((p) => ({
|
|
504
|
+
x: Math.max(minX, Math.min(maxX, cx + (p.x - (bx0 + bx1) / 2) * scaleX)),
|
|
505
|
+
y: Math.max(minY, Math.min(maxY, cy + (p.y - (by0 + by1) / 2) * scaleY)),
|
|
506
|
+
}));
|
|
507
|
+
|
|
508
|
+
// ── 4) 去矩形重叠(节点是 104×36 的横条,用矩形而不是圆判断)──
|
|
509
|
+
for (let pass = 0; pass < 120; pass += 1) {
|
|
510
|
+
let moved = false;
|
|
511
|
+
for (let i = 0; i < n; i += 1) {
|
|
512
|
+
for (let j = i + 1; j < n; j += 1) {
|
|
513
|
+
const dx = pos[j].x - pos[i].x;
|
|
514
|
+
const dy = pos[j].y - pos[i].y;
|
|
515
|
+
const ox = SEP_W - Math.abs(dx);
|
|
516
|
+
const oy = SEP_H - Math.abs(dy);
|
|
517
|
+
if (ox <= 0 || oy <= 0) continue;
|
|
518
|
+
moved = true;
|
|
519
|
+
if (ox < oy) {
|
|
520
|
+
const s = (dx >= 0 ? 1 : -1) * (ox / 2 + 0.5);
|
|
521
|
+
pos[i].x -= s;
|
|
522
|
+
pos[j].x += s;
|
|
523
|
+
} else {
|
|
524
|
+
const s = (dy >= 0 ? 1 : -1) * (oy / 2 + 0.5);
|
|
525
|
+
pos[i].y -= s;
|
|
526
|
+
pos[j].y += s;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
for (const p of pos) {
|
|
531
|
+
p.x = Math.max(minX, Math.min(maxX, p.x));
|
|
532
|
+
p.y = Math.max(minY, Math.min(maxY, p.y));
|
|
533
|
+
}
|
|
534
|
+
if (!moved) break;
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
nodes.forEach((node, i) => out.set(node.id, toAxis(pos[i].x, pos[i].y, gw, gh)));
|
|
538
|
+
return out;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
export default function OntologyGraph({
|
|
542
|
+
nodes,
|
|
543
|
+
edges,
|
|
544
|
+
entities,
|
|
545
|
+
loading,
|
|
546
|
+
height = 560,
|
|
547
|
+
onNodeClick,
|
|
548
|
+
activeId,
|
|
549
|
+
collapsed: collapsedProp,
|
|
550
|
+
flash,
|
|
551
|
+
focus,
|
|
552
|
+
}: OntologyGraphProps) {
|
|
553
|
+
const [mode, setMode] = useState<'layered' | 'force'>('layered');
|
|
554
|
+
// 已折叠的部门节点 id 集合(受控时用外部状态)
|
|
555
|
+
const [innerCollapsed] = useState<Set<string>>(new Set());
|
|
556
|
+
const collapsed = collapsedProp ?? innerCollapsed;
|
|
557
|
+
|
|
558
|
+
/**
|
|
559
|
+
* 双击锁定的聚焦节点。
|
|
560
|
+
*
|
|
561
|
+
* 交互约定:
|
|
562
|
+
* - 单击节点 = 打开右侧详情 + 临时高亮(hover 走 ECharts 的 emphasis,
|
|
563
|
+
* 鼠标离开就恢复 —— 这是 ECharts 自带行为,不归我们管);
|
|
564
|
+
* - 双击节点 = 把"该节点 + 它的直接邻居"**锁住**,簇外节点压暗,
|
|
565
|
+
* 鼠标怎么移动都不恢复;
|
|
566
|
+
* - 双击空白处(或按 Esc)= 解锁。
|
|
567
|
+
*
|
|
568
|
+
* 为什么要单独存这个状态:emphasis 是"鼠标态",ECharts 在 mouseout 时
|
|
569
|
+
* 会自动 downplay,没法靠 dispatchAction 长期保持。锁定必须落到数据样式上,
|
|
570
|
+
* 也就是重建一次 option。好在坐标是预先算好的(forceLayout 也是静态计算),
|
|
571
|
+
* 重建 option 不会让节点跑位,只是把样式换一套。
|
|
572
|
+
*/
|
|
573
|
+
const [lockedId, setLockedId] = useState<string | null>(null);
|
|
574
|
+
/** 图表实例:只在"选中高亮"时用 dispatchAction,避免重建 option 让力导重新布局 */
|
|
575
|
+
const chartRef = useRef<ChartLike | null>(null);
|
|
576
|
+
|
|
577
|
+
/**
|
|
578
|
+
* 图表容器宽度:笛卡尔坐标系的轴坐标要按实际宽度换算成像素,
|
|
579
|
+
* 所以必须量真实宽度(以前写死 900,窄屏下坐标会全部溢出)。
|
|
580
|
+
*/
|
|
581
|
+
const wrapRef = useRef<HTMLDivElement>(null);
|
|
582
|
+
const [wrapW, setWrapW] = useState(900);
|
|
583
|
+
|
|
584
|
+
useEffect(() => {
|
|
585
|
+
const el = wrapRef.current;
|
|
586
|
+
if (!el) return;
|
|
587
|
+
const measure = () => setWrapW(el.clientWidth || 900);
|
|
588
|
+
measure();
|
|
589
|
+
const ro = new ResizeObserver(measure);
|
|
590
|
+
ro.observe(el);
|
|
591
|
+
return () => ro.disconnect();
|
|
592
|
+
}, []);
|
|
593
|
+
|
|
594
|
+
/** 部门节点 id 集合(entity === 'department') */
|
|
595
|
+
const departmentIds = useMemo(() => {
|
|
596
|
+
const s = new Set<string>();
|
|
597
|
+
for (const n of nodes) if (n.entity === 'department') s.add(n.id);
|
|
598
|
+
return s;
|
|
599
|
+
}, [nodes]);
|
|
600
|
+
|
|
601
|
+
/**
|
|
602
|
+
* 兜底去重:同一对节点、同一条关系只保留一条边。
|
|
603
|
+
*
|
|
604
|
+
* ⚠️ 这是踩过的坑,值得说清楚:
|
|
605
|
+
* 图谱引擎(api/graph/route.ts)的 BFS 会从关系两端各展开一次
|
|
606
|
+
* (cur 是 from 时一次、是 to 时一次)。旧版引擎用**带方向**的键去重,
|
|
607
|
+
* 于是同一对节点上留下方向相反的两条边 —— ECharts 把同一对节点的多条 link
|
|
608
|
+
* 画成上下两条弧,看上去就是「同一条线画两遍」,页面上的「N 条关系」也翻倍
|
|
609
|
+
* (实测 29 个节点 50 条边,实际只有 25 条关系)。
|
|
610
|
+
*
|
|
611
|
+
* 引擎已经改成无向键去重;渲染层再防一次,是因为「项目里留着旧引擎」太容易发生:
|
|
612
|
+
* 脚手架是按模板整份复制过去的,改过的引擎不会自动回流到已生成的项目。
|
|
613
|
+
* 去重键带上 relation,所以**两个不同关系连同一对节点**(如"所属部门"和"组织部门")
|
|
614
|
+
* 仍然各画一条,不会被误合并。
|
|
615
|
+
*/
|
|
616
|
+
const uniqueEdges = useMemo(() => {
|
|
617
|
+
const seen = new Set<string>();
|
|
618
|
+
const out: GEdge[] = [];
|
|
619
|
+
for (const e of edges) {
|
|
620
|
+
const [lo, hi] = e.source < e.target ? [e.source, e.target] : [e.target, e.source];
|
|
621
|
+
const k = `${e.relation ?? e.label}|${lo}|${hi}`;
|
|
622
|
+
if (seen.has(k)) continue;
|
|
623
|
+
seen.add(k);
|
|
624
|
+
out.push(e);
|
|
625
|
+
}
|
|
626
|
+
return out;
|
|
627
|
+
}, [edges]);
|
|
628
|
+
|
|
629
|
+
/**
|
|
630
|
+
* 每个部门节点的下属数量(相邻**节点**数,不是边数)。
|
|
631
|
+
* ⚠️ 曾经按边计数,而旧引擎对同一条关系会生成双向两条边,结果部门 "(16)" 是实际下属(8)的两倍。
|
|
632
|
+
*/
|
|
633
|
+
const deptCounts = useMemo(() => {
|
|
634
|
+
const m = new Map<string, Set<string>>();
|
|
635
|
+
for (const id of departmentIds) m.set(id, new Set());
|
|
636
|
+
for (const e of uniqueEdges) {
|
|
637
|
+
if (departmentIds.has(e.source)) m.get(e.source)!.add(e.target);
|
|
638
|
+
if (departmentIds.has(e.target)) m.get(e.target)!.add(e.source);
|
|
639
|
+
}
|
|
640
|
+
return new Map([...m].map(([k, v]) => [k, v.size]));
|
|
641
|
+
}, [uniqueEdges, departmentIds]);
|
|
642
|
+
|
|
643
|
+
/** 计算实际要渲染的节点和边:折叠的部门节点,其相邻节点及相连边被排除 */
|
|
644
|
+
const { renderNodes, renderEdges } = useMemo(() => {
|
|
645
|
+
if (collapsed.size === 0) return { renderNodes: nodes, renderEdges: uniqueEdges };
|
|
646
|
+
|
|
647
|
+
// 需要隐藏的节点:所有"折叠部门节点的相邻节点"
|
|
648
|
+
const hidden = new Set<string>();
|
|
649
|
+
for (const depId of collapsed) {
|
|
650
|
+
for (const e of uniqueEdges) {
|
|
651
|
+
if (e.source === depId) hidden.add(e.target);
|
|
652
|
+
else if (e.target === depId) hidden.add(e.source);
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
// 部门节点本身始终显示,即使它被别的部门折叠
|
|
656
|
+
for (const id of departmentIds) hidden.delete(id);
|
|
657
|
+
|
|
658
|
+
const renderNodes = nodes.filter((n) => !hidden.has(n.id));
|
|
659
|
+
const renderEdges = uniqueEdges.filter(
|
|
660
|
+
(e) => !hidden.has(e.source) && !hidden.has(e.target)
|
|
661
|
+
);
|
|
662
|
+
return { renderNodes, renderEdges };
|
|
663
|
+
}, [nodes, uniqueEdges, collapsed, departmentIds]);
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* 锁定目标(数据刷新后节点可能被删/被折叠,这里做一次存在性校验,自动降级为"未锁定")。
|
|
667
|
+
*/
|
|
668
|
+
const locked = useMemo(
|
|
669
|
+
() => (lockedId && renderNodes.some((n) => n.id === lockedId) ? lockedId : null),
|
|
670
|
+
[lockedId, renderNodes]
|
|
671
|
+
);
|
|
672
|
+
|
|
673
|
+
/** 锁定节点的直接邻居(无向,和图上连线一致) */
|
|
674
|
+
const lockedNeighbors = useMemo(() => {
|
|
675
|
+
const s = new Set<string>();
|
|
676
|
+
if (!locked) return s;
|
|
677
|
+
for (const e of renderEdges) {
|
|
678
|
+
if (e.source === locked) s.add(e.target);
|
|
679
|
+
else if (e.target === locked) s.add(e.source);
|
|
680
|
+
}
|
|
681
|
+
return s;
|
|
682
|
+
}, [locked, renderEdges]);
|
|
683
|
+
|
|
684
|
+
/** 锁定节点名(提示文案用) */
|
|
685
|
+
const lockedLabel = useMemo(
|
|
686
|
+
() => renderNodes.find((n) => n.id === locked)?.label ?? '',
|
|
687
|
+
[locked, renderNodes]
|
|
688
|
+
);
|
|
689
|
+
|
|
690
|
+
/**
|
|
691
|
+
* 最大列的节点数 → 撑高图表。
|
|
692
|
+
* 一列 20 个节点时 560px 只够 25px/行,节点高 36px 必然互相压住;
|
|
693
|
+
* 保证每行至少 40px(height ≥ 节点数×40 + 上下边距)。
|
|
694
|
+
*/
|
|
695
|
+
const maxColCount = useMemo(() => {
|
|
696
|
+
const layerOf = new Map(entities.map((e, i) => [e.id, e.layer ?? i] as const));
|
|
697
|
+
const counts = new Map<number, number>();
|
|
698
|
+
for (const n of renderNodes) {
|
|
699
|
+
const l = layerOf.get(n.entity) ?? 99;
|
|
700
|
+
counts.set(l, (counts.get(l) ?? 0) + 1);
|
|
701
|
+
}
|
|
702
|
+
return Math.max(0, ...counts.values());
|
|
703
|
+
}, [renderNodes, entities]);
|
|
704
|
+
|
|
705
|
+
/** 列数(分层布局用):决定最小宽度 */
|
|
706
|
+
const layerCount = useMemo(() => {
|
|
707
|
+
const layerOf = new Map(entities.map((e, i) => [e.id, e.layer ?? i] as const));
|
|
708
|
+
const s = new Set<number>();
|
|
709
|
+
for (const n of renderNodes) s.add(layerOf.get(n.entity) ?? 99);
|
|
710
|
+
return Math.max(s.size, 1);
|
|
711
|
+
}, [renderNodes, entities]);
|
|
712
|
+
|
|
713
|
+
/**
|
|
714
|
+
* 最小图表宽度。
|
|
715
|
+
*
|
|
716
|
+
* 分层布局:节点是 104px 宽的方块,列间距至少 120px(104 + 16 间隙)。
|
|
717
|
+
* 画布不够宽时**不许把列挤到一起**(那会横向重叠),
|
|
718
|
+
* 而是把图表撑到最小宽度、外层出现横向滚动条 —— 图可以滚,不能糊。
|
|
719
|
+
*
|
|
720
|
+
* 力导布局:布局本身就是"按画布算坐标",宽度变窄应该**重新铺一遍**,
|
|
721
|
+
* 而不是撑出一个 760px 的画布被外层裁掉。所以这里只留一个很低的兜底,
|
|
722
|
+
* 右侧详情面板展开时图会跟着收窄重排(以前是直接裁掉右半边)。
|
|
723
|
+
*/
|
|
724
|
+
const MIN_COL_GAP = 120;
|
|
725
|
+
const minChartW =
|
|
726
|
+
mode === 'layered'
|
|
727
|
+
? (layerCount - 1) * MIN_COL_GAP + NODE_W + GRID.left + GRID.right
|
|
728
|
+
: 360;
|
|
729
|
+
const chartW = Math.max(wrapW, minChartW);
|
|
730
|
+
|
|
731
|
+
/**
|
|
732
|
+
* 画布高度 —— 两种模式的算法必须分开,这里踩过坑:
|
|
733
|
+
*
|
|
734
|
+
* 分层布局:一列最多 maxColCount 个节点,每行至少 40px,否则节点纵向压在一起。
|
|
735
|
+
*
|
|
736
|
+
* 力导布局:**不能**跟着列数撑高。列数是分层布局的概念 —— 力导里"同一 layer"
|
|
737
|
+
* 的 20 个叶子根本不在同一列,照搬会把画布拉成 812×848 的竖条。
|
|
738
|
+
* 力导收敛出的是团状,在竖条里只能填满高度、横向留一大片空白,
|
|
739
|
+
* 看起来就是"一团竖着的毛球"(截图里的样子)。改成按**节点密度**定高:
|
|
740
|
+
* 每个节点至少留 FORCE_CELL 面积,不够就往下撑(外层有滚动条)。
|
|
741
|
+
*/
|
|
742
|
+
const FORCE_CELL = (NODE_W + 40) * (NODE_H + 46);
|
|
743
|
+
const chartHeight =
|
|
744
|
+
mode === 'layered'
|
|
745
|
+
? Math.max(height, maxColCount * 40 + GRID.top + GRID.bottom)
|
|
746
|
+
: Math.max(
|
|
747
|
+
height,
|
|
748
|
+
Math.min(
|
|
749
|
+
Math.ceil((renderNodes.length * FORCE_CELL) / Math.max(chartW, 1)),
|
|
750
|
+
// 上限 2.2 倍:再高就只是滚动了,不如让节点稍微挤一点
|
|
751
|
+
Math.round(height * 2.2)
|
|
752
|
+
)
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
/** canvas 不认 CSS 变量:ECharts 的颜色必须用当前皮肤的字面值 */
|
|
756
|
+
const ink = useSkinInk();
|
|
757
|
+
|
|
758
|
+
const option = useMemo(() => {
|
|
759
|
+
// 两种模式都是"算好坐标再画":ECharts 只负责渲染,不负责跑模拟
|
|
760
|
+
const pos =
|
|
761
|
+
mode === 'layered'
|
|
762
|
+
? layoutNodes(renderNodes, entities, chartW, chartHeight, renderEdges)
|
|
763
|
+
: forceLayout(renderNodes, renderEdges, chartW, chartHeight);
|
|
764
|
+
const entLabel = new Map(entities.map((e) => [e.id, e.label]));
|
|
765
|
+
|
|
766
|
+
/** 聚焦簇:节点集合 + 边集合(无向,source/target 反过来也算同一条) */
|
|
767
|
+
const focusNodes = focus?.nodes?.length ? new Set(focus.nodes) : null;
|
|
768
|
+
const focusEdgeKeys = focus?.edges?.length
|
|
769
|
+
? new Set(focus.edges.flatMap((e) => [`${e.source}|${e.target}`, `${e.target}|${e.source}`]))
|
|
770
|
+
: null;
|
|
771
|
+
const curveness = mode === 'layered' ? 0.05 : 0.12;
|
|
772
|
+
|
|
773
|
+
/**
|
|
774
|
+
* 连线透明度按两端度数衰减(通用降噪,不看业务语义):
|
|
775
|
+
* 审批人、共享分类这类枢纽动辄连着十几条边,全部同样清晰就会糊成一张网;
|
|
776
|
+
* 度数越高 → 线越淡,"谁归属谁"这种稀有边保持清晰,hover/锁定时再全部点亮。
|
|
777
|
+
*/
|
|
778
|
+
const degMap = new Map<string, number>();
|
|
779
|
+
for (const e of renderEdges) {
|
|
780
|
+
degMap.set(e.source, (degMap.get(e.source) ?? 0) + 1);
|
|
781
|
+
degMap.set(e.target, (degMap.get(e.target) ?? 0) + 1);
|
|
782
|
+
}
|
|
783
|
+
const edgeOpacity = (s: string, t: string) =>
|
|
784
|
+
Math.max(0.22, Math.min(0.75, 1 / Math.sqrt(Math.max(degMap.get(s) ?? 1, degMap.get(t) ?? 1))));
|
|
785
|
+
|
|
786
|
+
return {
|
|
787
|
+
animationDuration: 400,
|
|
788
|
+
// 隐藏笛卡尔坐标系:坐标线性映射,不做"包围盒缩放铺满"(那会裁掉边缘节点)
|
|
789
|
+
xAxis: { type: 'value', min: 0, max: 100, show: false },
|
|
790
|
+
yAxis: { type: 'value', min: 0, max: 100, show: false },
|
|
791
|
+
grid: { left: GRID.left, right: GRID.right, top: GRID.top, bottom: GRID.bottom },
|
|
792
|
+
tooltip: {
|
|
793
|
+
confine: true,
|
|
794
|
+
backgroundColor: ink.card,
|
|
795
|
+
borderColor: ink.border,
|
|
796
|
+
textStyle: { color: ink.text, fontSize: 12 },
|
|
797
|
+
formatter: (p: unknown) => {
|
|
798
|
+
const d = (p as { dataType?: string; data?: Record<string, unknown> }).data;
|
|
799
|
+
if (!d) return '';
|
|
800
|
+
if (d.entity) {
|
|
801
|
+
const sub = d.subtitle
|
|
802
|
+
? `<br/><span style="color:#86909c">${String(d.subtitle)}</span>`
|
|
803
|
+
: '';
|
|
804
|
+
const link = d.link
|
|
805
|
+
? `<br/><a href="${String(d.link)}" style="color:${ink.primary}">打开详情 →</a>`
|
|
806
|
+
: '';
|
|
807
|
+
return `<b>${String(d.label ?? '')}</b><br/><span style="color:#86909c">${entLabel.get(String(d.entity)) ?? ''}</span>${sub}${link}`;
|
|
808
|
+
}
|
|
809
|
+
if (d.edgeLabel) return `关系:${String(d.edgeLabel)}`;
|
|
810
|
+
return '';
|
|
811
|
+
},
|
|
812
|
+
},
|
|
813
|
+
series: [
|
|
814
|
+
{
|
|
815
|
+
type: 'graph',
|
|
816
|
+
coordinateSystem: 'cartesian2d',
|
|
817
|
+
layout: 'none',
|
|
818
|
+
roam: true,
|
|
819
|
+
draggable: true,
|
|
820
|
+
/**
|
|
821
|
+
* 保存成功后的「闪一下」用 select 状态实现:
|
|
822
|
+
* - 走 dispatchAction({type:'select'}),不重建 option(力导不会重排)
|
|
823
|
+
* - emphasis 是悬停用的(focus:'adjacency' 会淡化其他节点),
|
|
824
|
+
* 拿它做"保存反馈"会一直保持,所以单开一个 select 样式
|
|
825
|
+
*/
|
|
826
|
+
selectedMode: 'single',
|
|
827
|
+
select: {
|
|
828
|
+
itemStyle: {
|
|
829
|
+
borderColor: ink.primary,
|
|
830
|
+
borderWidth: 2.6,
|
|
831
|
+
shadowBlur: 16,
|
|
832
|
+
shadowColor: 'rgba(78,95,60,0.55)',
|
|
833
|
+
},
|
|
834
|
+
label: { fontWeight: 700, color: ink.text },
|
|
835
|
+
},
|
|
836
|
+
symbol: 'roundRect',
|
|
837
|
+
symbolSize: [104, 36],
|
|
838
|
+
label: {
|
|
839
|
+
show: true,
|
|
840
|
+
position: 'inside',
|
|
841
|
+
fontSize: 12,
|
|
842
|
+
fontWeight: 500,
|
|
843
|
+
// ★ 深色文字:节点填充是浅色,深字才看得清
|
|
844
|
+
color: '#1d2129',
|
|
845
|
+
overflow: 'truncate',
|
|
846
|
+
width: 96,
|
|
847
|
+
},
|
|
848
|
+
edgeSymbol: ['none', 'arrow'],
|
|
849
|
+
edgeSymbolSize: [0, 7],
|
|
850
|
+
lineStyle: {
|
|
851
|
+
color: ink.edge,
|
|
852
|
+
width: 1.1,
|
|
853
|
+
curveness: mode === 'layered' ? 0.05 : 0.12,
|
|
854
|
+
opacity: 0.9,
|
|
855
|
+
},
|
|
856
|
+
edgeLabel: {
|
|
857
|
+
fontSize: 10,
|
|
858
|
+
color: ink.text2,
|
|
859
|
+
backgroundColor: ink.card,
|
|
860
|
+
padding: [2, 4],
|
|
861
|
+
borderRadius: 3,
|
|
862
|
+
formatter: (p: unknown) =>
|
|
863
|
+
(p as { data?: { edgeLabel?: string } }).data?.edgeLabel ?? '',
|
|
864
|
+
},
|
|
865
|
+
emphasis: {
|
|
866
|
+
/**
|
|
867
|
+
* 锁定态自带领簇高亮,此时 hover 不该再 blur 别人 ——
|
|
868
|
+
* 否则鼠标扫过任何一个节点,锁定的那一簇会被一起压暗,看着像"锁没了"。
|
|
869
|
+
*/
|
|
870
|
+
focus: locked ? 'none' : 'adjacency',
|
|
871
|
+
label: { fontSize: 12.5, fontWeight: 600, color: ink.text },
|
|
872
|
+
lineStyle: { width: 2.2, color: ink.primary, opacity: 1 },
|
|
873
|
+
edgeLabel: { show: !locked },
|
|
874
|
+
},
|
|
875
|
+
blur: {
|
|
876
|
+
itemStyle: { opacity: 0.22 },
|
|
877
|
+
label: { opacity: 0.22 },
|
|
878
|
+
lineStyle: { opacity: 0.1 },
|
|
879
|
+
},
|
|
880
|
+
data: renderNodes.map((n) => {
|
|
881
|
+
const p = pos.get(n.id);
|
|
882
|
+
// 部门节点显示下属数量:展开 `名称 (N)`,折叠 `名称 (+N)`
|
|
883
|
+
let name = n.label;
|
|
884
|
+
if (departmentIds.has(n.id)) {
|
|
885
|
+
const c = deptCounts.get(n.id) ?? 0;
|
|
886
|
+
name = collapsed.has(n.id) ? `${n.label} (+${c})` : `${n.label} (${c})`;
|
|
887
|
+
}
|
|
888
|
+
const inFocus = focusNodes ? focusNodes.has(n.id) : null;
|
|
889
|
+
/**
|
|
890
|
+
* 高亮来源有两个,取并集:
|
|
891
|
+
* - 双击锁定:锁定节点 + 它的直接邻居;
|
|
892
|
+
* - 业务聚焦(focus prop):接口下发的一簇节点。
|
|
893
|
+
* 簇外节点压暗但不隐藏 —— 保留上下文,才知道这簇关联落在图上的哪一片。
|
|
894
|
+
*/
|
|
895
|
+
const isLock = locked === n.id;
|
|
896
|
+
const isNear = locked ? lockedNeighbors.has(n.id) : false;
|
|
897
|
+
const hot = isLock || isNear || inFocus === true;
|
|
898
|
+
const dimmed = !hot && (locked !== null || inFocus !== null);
|
|
899
|
+
return {
|
|
900
|
+
id: n.id,
|
|
901
|
+
name,
|
|
902
|
+
// 笛卡尔坐标系用 value: [x, y](0~100 轴坐标)
|
|
903
|
+
value: p ? [p.x, p.y] : undefined,
|
|
904
|
+
entity: n.entity,
|
|
905
|
+
subtitle: n.subtitle,
|
|
906
|
+
link: n.link,
|
|
907
|
+
itemStyle: {
|
|
908
|
+
color: n.color || '#f2f3f5',
|
|
909
|
+
borderColor: hot ? ink.primary : n.borderColor || ink.border,
|
|
910
|
+
borderWidth: isLock ? 2.8 : hot ? 2.2 : 1.2,
|
|
911
|
+
opacity: dimmed ? 0.2 : 1,
|
|
912
|
+
shadowBlur: isLock ? 14 : hot ? 9 : 4,
|
|
913
|
+
shadowColor: isLock
|
|
914
|
+
? 'rgba(78,95,60,0.45)'
|
|
915
|
+
: hot
|
|
916
|
+
? 'rgba(78,95,60,0.3)'
|
|
917
|
+
: 'rgba(0,0,0,0.06)',
|
|
918
|
+
},
|
|
919
|
+
label: dimmed
|
|
920
|
+
? { color: '#9AA08E', opacity: 0.6 }
|
|
921
|
+
: isLock
|
|
922
|
+
? { fontWeight: 700 }
|
|
923
|
+
: isNear
|
|
924
|
+
? { fontWeight: 600 }
|
|
925
|
+
: undefined,
|
|
926
|
+
};
|
|
927
|
+
}),
|
|
928
|
+
links: renderEdges
|
|
929
|
+
.filter(
|
|
930
|
+
(e) =>
|
|
931
|
+
renderNodes.some((n) => n.id === e.source) &&
|
|
932
|
+
renderNodes.some((n) => n.id === e.target)
|
|
933
|
+
)
|
|
934
|
+
.map((e) => {
|
|
935
|
+
const on = focusEdgeKeys
|
|
936
|
+
? focusEdgeKeys.has(`${e.source}|${e.target}`)
|
|
937
|
+
: false;
|
|
938
|
+
// 锁定态只高亮"从锁定节点射出去"的边,邻居之间的横线保持压暗
|
|
939
|
+
const touchLock = locked !== null && (e.source === locked || e.target === locked);
|
|
940
|
+
const hotEdge = touchLock || on;
|
|
941
|
+
const anyHighlight = locked !== null || focusEdgeKeys !== null;
|
|
942
|
+
return {
|
|
943
|
+
source: e.source,
|
|
944
|
+
target: e.target,
|
|
945
|
+
edgeLabel: e.label,
|
|
946
|
+
// 高亮时:簇外的线几乎透明,簇内的线加粗成主色并直接显示关系名
|
|
947
|
+
lineStyle: anyHighlight
|
|
948
|
+
? hotEdge
|
|
949
|
+
? { width: touchLock ? 2.4 : 2.6, color: ink.primary, opacity: 1, curveness }
|
|
950
|
+
: { width: 1, opacity: 0.07, curveness }
|
|
951
|
+
: { opacity: edgeOpacity(e.source, e.target), curveness },
|
|
952
|
+
label: hotEdge
|
|
953
|
+
? {
|
|
954
|
+
show: true,
|
|
955
|
+
formatter: () => e.label,
|
|
956
|
+
fontSize: 10,
|
|
957
|
+
color: ink.primaryDark,
|
|
958
|
+
backgroundColor: ink.card,
|
|
959
|
+
padding: [2, 4],
|
|
960
|
+
borderRadius: 3,
|
|
961
|
+
}
|
|
962
|
+
: undefined,
|
|
963
|
+
};
|
|
964
|
+
}),
|
|
965
|
+
},
|
|
966
|
+
],
|
|
967
|
+
};
|
|
968
|
+
}, [
|
|
969
|
+
renderNodes,
|
|
970
|
+
renderEdges,
|
|
971
|
+
entities,
|
|
972
|
+
mode,
|
|
973
|
+
departmentIds,
|
|
974
|
+
deptCounts,
|
|
975
|
+
collapsed,
|
|
976
|
+
focus,
|
|
977
|
+
locked,
|
|
978
|
+
lockedNeighbors,
|
|
979
|
+
// 坐标是按画布实际尺寸算的,尺寸变了必须重算(否则窄屏下所有节点挤在左上角)
|
|
980
|
+
chartW,
|
|
981
|
+
chartHeight,
|
|
982
|
+
// 换皮肤要换 canvas 颜色(ink 是字面色),必须重算 option
|
|
983
|
+
ink,
|
|
984
|
+
]);
|
|
985
|
+
|
|
986
|
+
/**
|
|
987
|
+
* 选中节点的高亮:走 dispatchAction,而不是把 activeId 塞进 option。
|
|
988
|
+
* 力导布局下 setOption 会重跑模拟,节点会"跑掉",选中体验极差。
|
|
989
|
+
*
|
|
990
|
+
* locked 进依赖是因为锁定会重建 option(样式换一套),重建后这层高亮会被清掉,
|
|
991
|
+
* 需要按当前 activeId 再打一次。锁定期间则不打 —— 那个高亮是鼠标态,和锁定态叠加会互相打架。
|
|
992
|
+
*/
|
|
993
|
+
useEffect(() => {
|
|
994
|
+
const chart = chartRef.current;
|
|
995
|
+
if (!chart) return;
|
|
996
|
+
chart.dispatchAction({ type: 'downplay', seriesIndex: 0 });
|
|
997
|
+
if (!activeId || locked) return;
|
|
998
|
+
const idx = renderNodes.findIndex((n) => n.id === activeId);
|
|
999
|
+
if (idx >= 0) chart.dispatchAction({ type: 'highlight', seriesIndex: 0, dataIndex: idx });
|
|
1000
|
+
}, [activeId, renderNodes, locked]);
|
|
1001
|
+
|
|
1002
|
+
/**
|
|
1003
|
+
* 「保存成功 → 节点闪几下」。
|
|
1004
|
+
*
|
|
1005
|
+
* 为什么要闪:保存后图会重新拉取、节点也会重排,用户很难一眼找到刚改的那个。
|
|
1006
|
+
* 这里用 select 状态做 2 次脉冲,最后保持选中约 3 秒再解除 ——
|
|
1007
|
+
* 整个过程只 dispatchAction,不改 option,所以力导布局不会重跑。
|
|
1008
|
+
*/
|
|
1009
|
+
const flashedRef = useRef<string>('');
|
|
1010
|
+
useEffect(() => {
|
|
1011
|
+
const chart = chartRef.current;
|
|
1012
|
+
if (!chart || !flash) return;
|
|
1013
|
+
const key = `${flash.id}|${flash.nonce}`;
|
|
1014
|
+
if (flashedRef.current === key) return;
|
|
1015
|
+
const idx = renderNodes.findIndex((n) => n.id === flash.id);
|
|
1016
|
+
if (idx < 0) return;
|
|
1017
|
+
flashedRef.current = key;
|
|
1018
|
+
|
|
1019
|
+
let dead = false;
|
|
1020
|
+
const timers: ReturnType<typeof setTimeout>[] = [];
|
|
1021
|
+
const set = (on: boolean) =>
|
|
1022
|
+
chart.dispatchAction({
|
|
1023
|
+
type: on ? 'select' : 'unselect',
|
|
1024
|
+
seriesIndex: 0,
|
|
1025
|
+
dataIndex: idx,
|
|
1026
|
+
});
|
|
1027
|
+
const at = (ms: number, on: boolean) =>
|
|
1028
|
+
timers.push(
|
|
1029
|
+
setTimeout(() => {
|
|
1030
|
+
if (!dead) set(on);
|
|
1031
|
+
}, ms)
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
// 60ms:等 setOption 把新数据渲染完(reload 后 option 会整体替换,选中态会被重置)
|
|
1035
|
+
at(60, true);
|
|
1036
|
+
at(620, false);
|
|
1037
|
+
at(900, true);
|
|
1038
|
+
at(1460, false);
|
|
1039
|
+
at(1740, true);
|
|
1040
|
+
at(4200, false);
|
|
1041
|
+
|
|
1042
|
+
return () => {
|
|
1043
|
+
dead = true;
|
|
1044
|
+
timers.forEach(clearTimeout);
|
|
1045
|
+
};
|
|
1046
|
+
}, [flash, renderNodes]);
|
|
1047
|
+
|
|
1048
|
+
const handleClick = (params: unknown) => {
|
|
1049
|
+
const p = params as { dataType?: string; data?: { id?: string } };
|
|
1050
|
+
if (p.dataType !== 'node' || !p.data?.id) return;
|
|
1051
|
+
const n = nodes.find((x) => x.id === p.data!.id);
|
|
1052
|
+
// 点击统一是"选中":部门节点也要能打开右侧面板(否则永远改不了/删不掉)
|
|
1053
|
+
if (n) onNodeClick?.(n);
|
|
1054
|
+
};
|
|
1055
|
+
|
|
1056
|
+
/**
|
|
1057
|
+
* 双击节点 → 锁定「该节点 + 它的直接邻居」。
|
|
1058
|
+
*
|
|
1059
|
+
* 这里**不再**调 onNodeClick:双击之前浏览器已经派发过两次 click,
|
|
1060
|
+
* 右侧面板早打开了,再调一次只是多打一遍详情接口。
|
|
1061
|
+
* 再双击同一个节点视为解锁(省得非要去点空白)。
|
|
1062
|
+
*/
|
|
1063
|
+
const handleDblClick = (params: unknown) => {
|
|
1064
|
+
const p = params as { dataType?: string; data?: { id?: string } };
|
|
1065
|
+
if (p.dataType !== 'node' || !p.data?.id) return;
|
|
1066
|
+
const id = p.data.id;
|
|
1067
|
+
setLockedId((cur) => (cur === id ? null : id));
|
|
1068
|
+
};
|
|
1069
|
+
|
|
1070
|
+
/**
|
|
1071
|
+
* 空白处双击 → 解锁。
|
|
1072
|
+
*
|
|
1073
|
+
* series 事件只对**图形元素**触发,点空白时 ECharts 根本不派发 click/dblclick,
|
|
1074
|
+
* 所以必须挂到 zrender 那一层:`target` 为空就说明点在空白处。
|
|
1075
|
+
*/
|
|
1076
|
+
const handleBlankDblClick = useCallback((e: unknown) => {
|
|
1077
|
+
if ((e as { target?: unknown }).target) return; // 点在节点/边/标签上,交给 series 事件
|
|
1078
|
+
setLockedId(null);
|
|
1079
|
+
}, []);
|
|
1080
|
+
|
|
1081
|
+
// 锁定期间 Esc 也能解锁:双击空白不好找的时候有个后路
|
|
1082
|
+
useEffect(() => {
|
|
1083
|
+
if (!lockedId) return;
|
|
1084
|
+
const onKey = (ev: KeyboardEvent) => {
|
|
1085
|
+
if (ev.key === 'Escape') setLockedId(null);
|
|
1086
|
+
};
|
|
1087
|
+
window.addEventListener('keydown', onKey);
|
|
1088
|
+
return () => window.removeEventListener('keydown', onKey);
|
|
1089
|
+
}, [lockedId]);
|
|
1090
|
+
|
|
1091
|
+
/** 图表实例就绪/销毁:把 zrender 层的空白双击挂上 / 摘掉 */
|
|
1092
|
+
const handleChart = useCallback(
|
|
1093
|
+
(c: ChartLike | null) => {
|
|
1094
|
+
const prev = chartRef.current;
|
|
1095
|
+
// ★ 必须判空:实例 dispose 之后 getZr() 会返回 null
|
|
1096
|
+
// (直接 prev.getZr().off() 会抛 "Cannot read properties of null (reading 'off')",
|
|
1097
|
+
// 整个页面白屏,而且只在切换视图/重挂载时才复现)
|
|
1098
|
+
const prevZr = prev?.getZr?.();
|
|
1099
|
+
if (prevZr) prevZr.off('dblclick', handleBlankDblClick);
|
|
1100
|
+
chartRef.current = c;
|
|
1101
|
+
const nextZr = c?.getZr?.();
|
|
1102
|
+
if (nextZr) nextZr.on('dblclick', handleBlankDblClick);
|
|
1103
|
+
},
|
|
1104
|
+
[handleBlankDblClick]
|
|
1105
|
+
);
|
|
1106
|
+
|
|
1107
|
+
if (!nodes.length && !loading) {
|
|
1108
|
+
return (
|
|
1109
|
+
<Empty
|
|
1110
|
+
description="没有图数据。请先在 src/lib/ontology.ts 里配好本体和关系,并补充演示数据。"
|
|
1111
|
+
style={{ padding: 60 }}
|
|
1112
|
+
/>
|
|
1113
|
+
);
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return (
|
|
1117
|
+
<div>
|
|
1118
|
+
<div
|
|
1119
|
+
style={{
|
|
1120
|
+
display: 'flex',
|
|
1121
|
+
alignItems: 'center',
|
|
1122
|
+
gap: 12,
|
|
1123
|
+
marginBottom: 8,
|
|
1124
|
+
flexWrap: 'wrap',
|
|
1125
|
+
}}
|
|
1126
|
+
>
|
|
1127
|
+
<Segmented
|
|
1128
|
+
size="small"
|
|
1129
|
+
value={mode}
|
|
1130
|
+
onChange={(v) => setMode(v as 'layered' | 'force')}
|
|
1131
|
+
options={[
|
|
1132
|
+
{ label: '分层布局', value: 'layered' },
|
|
1133
|
+
{ label: '力导布局', value: 'force' },
|
|
1134
|
+
]}
|
|
1135
|
+
/>
|
|
1136
|
+
<Typography.Text type="secondary" style={{ fontSize: fs(12) }}>
|
|
1137
|
+
{renderNodes.length} 个节点 · {renderEdges.length} 条关系
|
|
1138
|
+
{focus?.nodes?.length ? ` · 已聚焦 ${focus.nodes.length} 个关联实体` : ''}
|
|
1139
|
+
{locked ? (
|
|
1140
|
+
<span style={{ color: MILITARY.primary, fontWeight: 600 }}>
|
|
1141
|
+
{' · '}已锁定「{lockedLabel}」· 双击空白处或按 Esc 解锁
|
|
1142
|
+
</span>
|
|
1143
|
+
) : (
|
|
1144
|
+
' · 双击节点锁定关联高亮 · 悬停查看详情 · 点击节点可编辑'
|
|
1145
|
+
)}
|
|
1146
|
+
</Typography.Text>
|
|
1147
|
+
<span style={{ flex: 1 }} />
|
|
1148
|
+
<Space size={6} wrap>
|
|
1149
|
+
{entities.map((e) => (
|
|
1150
|
+
<Tooltip
|
|
1151
|
+
key={e.id}
|
|
1152
|
+
title={`${e.label}(${nodes.filter((n) => n.entity === e.id).length} 个)`}
|
|
1153
|
+
>
|
|
1154
|
+
<span
|
|
1155
|
+
style={{
|
|
1156
|
+
display: 'inline-flex',
|
|
1157
|
+
alignItems: 'center',
|
|
1158
|
+
fontSize: fs(12),
|
|
1159
|
+
color: '#1d2129',
|
|
1160
|
+
padding: '1px 8px',
|
|
1161
|
+
borderRadius: 10,
|
|
1162
|
+
border: '1px solid #e5e6eb',
|
|
1163
|
+
background: e.color,
|
|
1164
|
+
}}
|
|
1165
|
+
>
|
|
1166
|
+
{e.label}
|
|
1167
|
+
</span>
|
|
1168
|
+
</Tooltip>
|
|
1169
|
+
))}
|
|
1170
|
+
</Space>
|
|
1171
|
+
</div>
|
|
1172
|
+
|
|
1173
|
+
<div
|
|
1174
|
+
ref={wrapRef}
|
|
1175
|
+
style={{
|
|
1176
|
+
overflowX: 'auto',
|
|
1177
|
+
border: `1px solid ${MILITARY.borderSoft}`,
|
|
1178
|
+
borderRadius: 10,
|
|
1179
|
+
background: MILITARY.paper,
|
|
1180
|
+
}}
|
|
1181
|
+
>
|
|
1182
|
+
{/* 内层定宽:窄屏时图表保持最小宽度,由外层滚动,而不是把列挤变形 */}
|
|
1183
|
+
<div style={{ width: chartW }}>
|
|
1184
|
+
<EChart
|
|
1185
|
+
option={option}
|
|
1186
|
+
height={chartHeight}
|
|
1187
|
+
loading={loading}
|
|
1188
|
+
// 双击锁定:单击仍是"打开详情",双击额外把这一簇钉住
|
|
1189
|
+
onEvents={{ click: handleClick, dblclick: handleDblClick }}
|
|
1190
|
+
onChart={handleChart}
|
|
1191
|
+
/>
|
|
1192
|
+
</div>
|
|
1193
|
+
</div>
|
|
1194
|
+
</div>
|
|
1195
|
+
);
|
|
1196
|
+
}
|