NetworkGraph.tsx
37.5 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
import React, { useEffect, useMemo, useRef, useState } from 'react';
import cytoscape from 'cytoscape';
// @ts-ignore cytoscape-fcose ships no type declarations
import fcose from 'cytoscape-fcose';
import type { Ontology, NavNode } from '../ontology/types';
// 用成熟的 Cytoscape.js + fCoSE 力导向布局渲染本体网络图。
// fCoSE 是官方推荐的“最小化交叉、聚类清晰”的力导向布局。
// 节点模型:
// - 实体 = compound(复合)框;框内只保留自身「标识(主键)」子节点(显示自己模块内的名字)。
// - 普通属性 = 独立节点(卫星),用连线挂到实体框外,不再嵌进框内。
// - 跨聚合引用 = 独立「引用节点」(专色 C 紫),落在引用方与被引用实体之间,
// 显示的是「引用地的表述」(引用方 fk 字段的 zh,如 客户编号2),常驻可见。
// - 标识被隐藏后,实体框变空,模块名回到框内居中(不再浮在空框上方)。
// 左侧目录内置为组件自身的左栏(不再 portal 到外部侧边栏),严格按本体的 navigations(M3 导航树)
// 组织;无导航时退化为按聚合平铺。
cytoscape.use(fcose);
const COL: Record<string, string[]> = {
entity: ['#eef2fd', '#5b7fe0', '#33529e'],
behavior: ['#e9f7ee', '#4fae6e', '#2f7d4c'],
rule: ['#fdf3e8', '#e0944a', '#9a5e1d'],
event: ['#fbe3ef', '#e8579b', '#b83b7e'],
nav: ['#f2f3f5', '#8e8e96', '#3f3f46'],
identifier: ['#fdf3d6', '#caa011', '#7a5c00'], // 标识 A(金)
attribute: ['#eef0f3', '#9298a3', '#4b5058'], // 属性 B(灰)——与标识同形不同色
bridge: ['#efe7fb', '#7c4dcf', '#4d2a94'], // 引用节点 C(紫)——跨实体引用的落点
};
const TYPE_CN: Record<string, string> = { entity: '实体', behavior: '行为', rule: '规则', event: '事件', nav: '目录', identifier: '标识', attribute: '属性', bridge: '引用' };
// 属性 / 标识作为独立节点:合成 id = attr::<对象id>::<字段名>(字段名仅在对象内唯一,故带上对象 id 前缀)。
// 标识 = is_primary_key 的属性(M1 导入时被放到 attributes 首位),其余为普通属性。
const attrNodeId = (oid: string, name: string) => `attr::${oid}::${name}`;
const attrNodeType = (a: any) => (a.is_primary_key ? 'identifier' : 'attribute');
// 跨聚合引用列(relationship.fk_field):不单独成普通属性节点,而是生成一个「引用节点」,
// 显示引用方对该外键列的表述(如订单里的 客户编号2),落在引用方与被引用实体之间。
const fkFields = (o: any) => new Set((o.relationships || []).filter((r: any) => r.fk_field).map((r: any) => r.fk_field));
const attrNodeIds = (o: any) => { const fk = fkFields(o); return (o.attributes || []).filter((a: any) => !fk.has(a.name)).map((a: any) => attrNodeId(o.id, a.name)); };
// 引用节点 id:按(引用方对象, 关系)唯一。每条跨聚合引用一个,互不共享。
const refNodeId = (ownerId: string, rel: any) => `ref::${ownerId}::${rel.fk_field || rel.name}`;
// 引用方对该外键列的表述(引用地的 zh);取不到时退回关系名。
const refLabel = (o: any, rel: any) => {
const a = (o.attributes || []).find((x: any) => x.name === rel.fk_field);
return a?.label || rel.label || rel.name;
};
// M3 导航节点是“空节点”:自身无实体/行为内容,只作为界面目录入口。
function findNav(nodes: NavNode[] | undefined, id: string): NavNode | null {
for (const n of nodes || []) { if (`nav__${n.id}` === id) return n; const f = findNav(n.children, id); if (f) return f; }
return null;
}
function nameOf(ontology: Ontology, id: string): string {
if (typeof id === 'string' && id.startsWith('nav__')) { const n = findNav(ontology.navigations, id); if (n) return n.zh || n.name || n.id; }
return ontology.objects.find((o) => o.id === id)?.name
|| ontology.behaviors.find((b) => b.id === id)?.name
|| ontology.rules.find((r) => r.id === id)?.name
|| ontology.events.find((e) => e.id === id)?.name || id;
}
function describe(ontology: Ontology, id: string): any {
// 引用节点:显示引用地的表述 + 引用方 / 指向
if (typeof id === 'string' && id.startsWith('ref::')) {
const [, ownerId, fk] = id.split('::');
const owner = ontology.objects.find((x) => x.id === ownerId);
const rel = owner && (owner.relationships || []).find((r) => (r.fk_field || r.name) === fk);
const target = rel && ontology.objects.find((x) => x.id === rel.target_object_id);
return {
type: 'bridge',
title: owner && rel ? refLabel(owner, rel) : fk,
lines: [
'跨实体引用(连接点)',
owner ? `引用方: ${owner.name}` : null,
target ? `指向: ${target.name}` : null,
rel?.fk_field ? `字段 ${rel.fk_field}` : null,
].filter(Boolean),
};
}
if (typeof id === 'string' && id.startsWith('attr::')) {
const rest = id.slice('attr::'.length);
const sep = rest.indexOf('::');
const oid = rest.slice(0, sep), fname = rest.slice(sep + 2);
const o = ontology.objects.find((x) => x.id === oid);
const a: any = o && (o.attributes || []).find((x) => x.name === fname);
if (a) {
const referenced = a.is_primary_key && ontology.objects.some((x) => (x.relationships || []).some((r) => r.fk_field && r.target_object_id === oid));
return {
type: attrNodeType(a),
title: a.label || a.name,
lines: [
`字段 ${a.name} · ${a.type}`,
a.is_primary_key ? '主键(标识)' : null,
referenced ? '被其他实体引用' : null,
[a.required ? '必填' : null, a.unique ? '唯一' : null].filter(Boolean).join(' · ') || null,
a.enum_values?.length ? `枚举: ${a.enum_values.join('、')}` : null,
o ? `属于: ${o.name}` : null,
a.description,
].filter(Boolean),
};
}
}
if (typeof id === 'string' && id.startsWith('nav__')) {
const n = findNav(ontology.navigations, id);
if (n) { const kids = (n.children || []).map((c) => c.zh || c.name).filter(Boolean);
return { type: 'nav', title: n.zh || n.name || n.id, lines: [
n.link ? `入口 → ${nameOf(ontology, n.link)}` : '分组节点(空节点)',
kids.length ? `子项: ${kids.join('、')}` : null,
].filter(Boolean) }; }
}
const o = ontology.objects.find((x) => x.id === id);
if (o) {
// 关系已由图上的连线表达,卡片内不再重复列出
return { type: 'entity', title: `${o.name} (${o.alias})`, lines: [o.description].filter(Boolean) };
}
const b = ontology.behaviors.find((x) => x.id === id);
if (b) return { type: 'behavior', title: b.name, lines: [
`作用于: ${nameOf(ontology, b.owner_object_id)}`,
b.applied_rules?.length ? `规则: ${b.applied_rules.map((r) => nameOf(ontology, r)).join(', ')}` : null,
b.produced_events?.length ? `事件: ${b.produced_events.map((e) => nameOf(ontology, e)).join(', ')}` : null,
].filter(Boolean) };
const r = ontology.rules.find((x) => x.id === id);
if (r) return { type: 'rule', title: r.name, lines: [`${r.type} · ${r.enforced ? '可强制' : '仅说明'}`, r.expression].filter(Boolean) };
const e = ontology.events.find((x) => x.id === id);
if (e) { const by = ontology.behaviors.filter((x) => (x.produced_events || []).includes(id)).map((x) => x.name);
return { type: 'event', title: e.name, lines: [by.length ? `由 ${by.join(', ')} 产出` : null, e.description].filter(Boolean) }; }
return null;
}
function buildElements(ontology: Ontology): any[] {
const els: any[] = [];
const clen = (s: string) => [...s].length;
ontology.objects.forEach((o) => {
const bw = Math.max(56, Math.min(120, clen(o.name) * 14 + 22));
els.push({ data: { id: o.id, label: o.name, type: 'entity', bw, bh: 34, bfont: 12, btw: bw - 14, bmy: 0 } });
const fk = fkFields(o);
(o.attributes || []).forEach((a) => {
if (fk.has(a.name)) return; // 外键引用列不成普通属性节点(由引用节点表达)
const nid = attrNodeId(o.id, a.name);
const lb = a.label || a.name;
const w = Math.max(44, Math.min(116, clen(lb) * 11 + 16));
const base = { id: nid, label: lb, type: attrNodeType(a), bw: w, bh: 24, bfont: 9, btw: w - 12, bmy: 0 };
// 标识(主键)→ 嵌进实体框(compound 子节点);普通属性 → 独立卫星节点(连线在下方补)
els.push({ data: a.is_primary_key ? { ...base, parent: o.id } : base });
});
});
ontology.behaviors.forEach((b) => els.push({ data: { id: b.id, label: b.name, type: 'behavior', bw: 58, bh: 58, bfont: 9.5, btw: 46, bmy: 0 } }));
ontology.rules.forEach((r) => els.push({ data: { id: r.id, label: r.name, type: 'rule', bw: 68, bh: 68, bfont: 8.5, btw: 42, bmy: 0 } }));
ontology.events.forEach((e) => els.push({ data: { id: e.id, label: e.name, type: 'event', bw: 56, bh: 56, bfont: 8.5, btw: 34, bmy: 8 } }));
// M3 导航:每个目录节点作为“空节点”入图,按层级相连,叶子连到所引用的聚合根。
// 层级连线标注级别:顶层→子=一级,再往下=二级……(取子节点深度)
const CN_NUM = ['一', '二', '三', '四', '五', '六', '七', '八', '九', '十'];
const levelLabel = (d: number) => `${CN_NUM[d - 1] || d}级`;
const navMeta: any[] = [];
const walkNav = (n: NavNode, parentGid: string | null, depth: number) => {
const gid = `nav__${n.id}`;
const label = n.zh || n.name || n.id;
const bw = Math.max(56, Math.min(150, clen(label) * 14 + 22));
els.push({ data: { id: gid, label, type: 'nav', bw, bh: 32, bfont: 11, btw: bw - 14, bmy: 0 } });
navMeta.push({ gid, parentGid, link: n.link || null, depth });
(n.children || []).forEach((c) => walkNav(c, gid, depth + 1));
};
(ontology.navigations || []).forEach((n) => walkNav(n, null, 0));
const ids = new Set(els.map((e) => e.data.id));
const edge = (s: string, t: string, label: string, kind: string, i: any) => els.push({ data: { id: `${s}_${kind}_${t}_${i}`, source: s, target: t, label, hlcolor: COL[kind][1] } });
ontology.behaviors.forEach((b, i) => {
if (ids.has(b.owner_object_id)) edge(b.id, b.owner_object_id, 'ownerEntity', 'behavior', i);
(b.applied_rules || []).forEach((r, j) => ids.has(r) && edge(b.id, r, 'appliedRules', 'rule', `${i}${j}`));
(b.produced_events || []).forEach((e, j) => ids.has(e) && edge(b.id, e, 'produces', 'event', `${i}${j}`));
});
// 普通属性(卫星)→ 实体框:连线(标识在框内由 compound 包含表达,不连线)
ontology.objects.forEach((o, i) => (o.attributes || []).forEach((a, j) => {
if (a.is_primary_key || fkFields(o).has(a.name)) return;
const nid = attrNodeId(o.id, a.name);
if (ids.has(nid)) edge(o.id, nid, '', 'attribute', `a${i}_${j}`);
}));
ontology.objects.forEach((o, i) => (o.relationships || []).forEach((rel, j) => {
if (!ids.has(rel.target_object_id)) return;
// 跨聚合引用:引用方 →〔引用节点(引用地表述,如 客户编号2)〕→ 被引用实体
if (rel.fk_field) {
const rid = refNodeId(o.id, rel);
const lb = refLabel(o, rel);
const w = Math.max(44, Math.min(120, clen(lb) * 11 + 16));
els.push({ data: { id: rid, label: lb, type: 'attribute', bridge: true, bw: w, bh: 24, bfont: 9, btw: w - 12, bmy: 0 } });
edge(o.id, rid, '引用', 'bridge', `${i}${j}`);
edge(rid, rel.target_object_id, '', 'bridge', `${i}${j}b`);
return;
}
edge(o.id, rel.target_object_id, rel.label || rel.name, 'entity', `${i}${j}`);
}));
navMeta.forEach((m, i) => {
if (m.parentGid) edge(m.parentGid, m.gid, levelLabel(m.depth), 'nav', `nv${i}`);
if (m.link && ids.has(m.link)) edge(m.gid, m.link, '入口', 'nav', `nl${i}`);
});
return els;
}
// 聚合层级:聚合根 → 子实体 / 跨聚合引用 / 行为(→ 规则、事件)。
function buildTree(ontology: Ontology) {
const roots = ontology.objects.filter((o) => !o.parent_id);
const usedRules = new Set<string>(); const usedEvents = new Set<string>();
const tree = roots.map((root) => {
const subs = ontology.objects.filter((o) => o.parent_id === root.id);
const subIds = new Set(subs.map((s) => s.id));
const refs: any[] = [];
[root, ...subs].forEach((o) => (o.relationships || []).forEach((rel) => {
if (subIds.has(rel.target_object_id) || rel.target_object_id === root.id) return; // 组合已列为子实体
const t = ontology.objects.find((x) => x.id === rel.target_object_id);
if (t) refs.push({ key: `${o.id}_${rel.name}`, target: t.id, label: `${rel.fk_field || rel.name} → ${t.name}`, refNode: rel.fk_field ? refNodeId(o.id, rel) : null });
}));
const behaviors = ontology.behaviors
.filter((b) => b.owner_object_id === root.id || subIds.has(b.owner_object_id))
.map((b) => {
const rules = (b.applied_rules || []).map((id) => ontology.rules.find((r) => r.id === id)).filter(Boolean) as any[];
const events = (b.produced_events || []).map((id) => ontology.events.find((e) => e.id === id)).filter(Boolean) as any[];
rules.forEach((r) => usedRules.add(r.id)); events.forEach((e) => usedEvents.add(e.id));
return { b, rules, events };
});
return { root, subs, refs, behaviors };
});
// 挂不上任何聚合的节点兜底展示,保证目录覆盖图上每个节点
const knownObj = new Set(ontology.objects.map((o) => o.id));
const others = [
...ontology.behaviors.filter((b) => !knownObj.has(b.owner_object_id)).map((b) => ({ id: b.id, label: b.name, type: 'behavior' })),
...ontology.rules.filter((r) => !usedRules.has(r.id)).map((r) => ({ id: r.id, label: r.name, type: 'rule' })),
...ontology.events.filter((e) => !usedEvents.has(e.id)).map((e) => ({ id: e.id, label: e.name, type: 'event' })),
];
return { tree, others };
}
// 聚合簇覆盖的全部节点 id(根 + 子实体 + 跨聚合引用节点/目标 + 行为 + 规则 + 事件)。
// 引用目标(如 订单明细→产品)纳入本簇:选中本模块时,即使产品属于别的模块也一并显示。
function clusterIds(t: any): any[] {
const ids = [t.root.id, ...attrNodeIds(t.root),
...t.subs.flatMap((s: any) => [s.id, ...attrNodeIds(s)]),
...t.refs.map((r: any) => r.target), ...t.refs.map((r: any) => r.refNode).filter(Boolean)];
t.behaviors.forEach(({ b, rules, events }: any) => ids.push(b.id, ...rules.map((r: any) => r.id), ...events.map((e: any) => e.id)));
return ids;
}
// 一行:缩进由外层 nest() 容器提供,这里只管内容与选中态。
function Row({ type = 'entity', label, alias, note, bold, selected, chev, onChev, onPick }: any) {
const c = COL[type] || COL.entity;
return (
<div onClick={onPick} title={label}
style={{ display: 'flex', alignItems: 'center', gap: 7, padding: '4px 8px', borderRadius: 7, cursor: 'pointer', background: selected ? c[0] : 'transparent', fontSize: 12, color: '#3f3f46', lineHeight: 1.45, userSelect: 'none' }}>
<span onClick={onChev ? (e: any) => { e.stopPropagation(); onChev(); } : undefined}
style={{ width: 12, textAlign: 'center', color: '#b4b4ba', fontSize: 9, flex: 'none', cursor: onChev ? 'pointer' : 'default' }}>
{chev === 'open' ? '▾' : chev === 'closed' ? '▸' : ''}
</span>
<span style={{ width: 8, height: 8, borderRadius: type === 'entity' || type === 'nav' ? 2 : 8, background: c[1], flex: 'none' }} />
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', fontWeight: bold ? 600 : 400 }}>
{label}{alias && <span style={{ color: '#b8b8be', fontWeight: 400, marginLeft: 6, fontSize: 11 }}>{alias}</span>}
</span>
{note && <span style={{ fontSize: 10, color: '#b4b4ba', flex: 'none' }}>{note}</span>}
</div>
);
}
const GroupLabel = ({ text, count }: any) => (
<div style={{ padding: '5px 6px 2px 8px', fontSize: 10, fontWeight: 600, letterSpacing: '.03em', color: '#aeaeb4', userSelect: 'none' }}>{text}{count != null ? ` · ${count}` : ''}</div>
);
// 按类型 + 孤儿 + 目录折叠 过滤:用 display:none(可逆),连线随端点自动隐藏。
// 孤儿 = 没有任何连到“类型可见”邻居的边(含 0 边的独立节点)。
// dirHidden = 目录里被折叠的聚合/导航分组所覆盖的全部节点 id。
function applyFilter(cy: any, hiddenTypes: Record<string, boolean>, hideOrphans: boolean, dirHidden: Set<any> = new Set()) {
if (!cy) return;
cy.batch(() => {
cy.nodes().forEach((n: any) => {
const t = n.data('type');
let disp = 'element';
// 引用节点常驻显示——即使隐藏了“标识/属性”类型,也保留两实体之间的连接点
if (n.data('bridge')) { n.style('display', 'element'); return; }
if (hiddenTypes[t] || dirHidden.has(n.id())) disp = 'none';
else if (hideOrphans) {
const hasNbr = n.connectedEdges().some((e: any) => {
const other = e.source().id() === n.id() ? e.target() : e.source();
return !hiddenTypes[other.data('type')] && !dirHidden.has(other.id());
});
if (!hasNbr) disp = 'none';
}
n.style('display', disp);
});
// 标识(框内)被隐藏后,实体框变空 → 让模块名回到框内居中(不再浮在空框上方)
cy.nodes('[type = "entity"]').forEach((n: any) => {
const hasVisibleChild = n.children().some((c: any) => c.style('display') !== 'none');
n.toggleClass('entity-empty', !hasVisibleChild);
});
});
// 不调用 fit:筛选时保持当前视图位置与缩放不变
}
const STYLE: any[] = [
{ selector: 'node', style: { label: 'data(label)', 'text-wrap': 'wrap', 'border-width': 1.6, 'transition-property': 'opacity', 'transition-duration': '0.15s' } },
// 叶子节点(属性/标识/行为/规则/事件/目录)用固定基准尺寸,标签居中
{ selector: 'node[type != "entity"]', style: { width: 'data(bw)', height: 'data(bh)', 'font-size': 'data(bfont)', 'text-max-width': 'data(btw)', 'text-valign': 'center', 'text-halign': 'center' } },
// 实体 = compound 父节点:自动裹住框内标识,半透明底,标签置顶
{ selector: 'node[type="entity"]', style: { shape: 'round-rectangle', 'background-color': COL.entity[0], 'background-opacity': 0.4, 'border-color': COL.entity[1], color: COL.entity[2], 'text-valign': 'top', 'text-halign': 'center', 'font-size': 12, 'font-weight': 700, 'text-margin-y': 3, padding: '12px', 'min-width': 44, 'min-height': 22 } },
{ selector: 'node[type="behavior"]', style: { shape: 'ellipse', 'background-color': COL.behavior[0], 'border-color': COL.behavior[1], color: COL.behavior[2] } },
{ selector: 'node[type="rule"]', style: { shape: 'diamond', 'background-color': COL.rule[0], 'border-color': COL.rule[1], color: COL.rule[2] } },
{ selector: 'node[type="event"]', style: { shape: 'triangle', 'background-color': COL.event[0], 'border-color': COL.event[1], color: COL.event[2], 'text-margin-y': 'data(bmy)' } },
// 目录节点=空节点:白底虚线边框,视觉上“空”
{ selector: 'node[type="nav"]', style: { shape: 'round-rectangle', 'background-color': '#ffffff', 'border-color': COL.nav[1], 'border-style': 'dashed', color: COL.nav[2] } },
// 标识 A(金)与属性 B(灰)同形(圆角矩形),只靠颜色区分
{ selector: 'node[type="identifier"]', style: { shape: 'round-rectangle', 'background-color': COL.identifier[0], 'border-color': COL.identifier[1], color: COL.identifier[2] } },
{ selector: 'node[type="attribute"]', style: { shape: 'round-rectangle', 'background-color': COL.attribute[0], 'border-color': COL.attribute[1], color: COL.attribute[2] } },
// 引用节点用属性灰(type=attribute),不再单独着色
// 实体框内标识被隐藏 → 空框:回到固定尺寸、实底、标签居中(模块名回到框内)
{ selector: 'node.entity-empty', style: { 'min-width': 'data(bw)', 'min-height': 'data(bh)', 'text-valign': 'center', 'text-halign': 'center', 'text-margin-y': 0, 'background-opacity': 1, padding: '0px' } },
{ selector: 'edge', style: { width: 1.3, 'line-color': '#dfe1e8', 'curve-style': 'bezier', label: 'data(label)', 'font-size': 8, color: '#b8bac4', 'text-rotation': 'autorotate', 'text-background-color': '#fbfbfc', 'text-background-opacity': 0.85, 'text-background-padding': 1 } },
{ selector: '.faded', style: { opacity: 0.12, 'text-opacity': 0.06 } },
{ selector: 'node.hl', style: { 'border-width': 3.5 } },
{ selector: 'edge.hl', style: { 'line-color': 'data(hlcolor)', color: 'data(hlcolor)' } },
];
export default function NetworkGraph({ ontology }: { ontology: Ontology }) {
const boxRef = useRef<HTMLDivElement | null>(null);
const cyRef = useRef<any>(null);
const tipRef = useRef<HTMLDivElement | null>(null);
const activeRef = useRef<any>(null);
const selRef = useRef(false);
const [tip, setTip] = useState<any>(null);
const [hiddenTypes, setHiddenTypes] = useState<Record<string, boolean>>({ nav: true }); // 默认隐藏目录节点(可在筛选面板点「目录」显示)
const [hideOrphans, setHideOrphans] = useState(false);
const filterRef = useRef<any>({ hiddenTypes: { nav: true }, hideOrphans: false, dirHidden: new Set() });
// ---- 目录数据:M3 导航树 + 聚合层级 ----
const { tree, others } = useMemo(() => buildTree(ontology), [ontology]);
const navs = useMemo(() => ontology.navigations || [], [ontology]);
const byRoot = useMemo(() => Object.fromEntries(tree.map((t) => [t.root.id, t])), [tree]);
// 未被任何导航叶子引用的聚合,兜底列在目录底部
const restTree = useMemo(() => {
if (!navs.length) return [];
const used = new Set<string>();
const mark = (n: NavNode) => { if (n.link) used.add(n.link); (n.children || []).forEach(mark); };
navs.forEach(mark);
return tree.filter((t) => !used.has(t.root.id));
}, [navs, tree]);
const [closedNavs, setClosedNavs] = useState<Set<string>>(() => new Set()); // 折叠的导航节点(含叶子)
const [closedRoots, setClosedRoots] = useState<Set<string>>(() => new Set()); // 无导航退化模式下折叠的聚合
const [openBehs, setOpenBehs] = useState<Set<string>>(() => new Set());
const [selId, setSelId] = useState<string | null>(null);
const pickRef = useRef<((id: string) => void) | null>(null);
const pick = (id: string) => pickRef.current && pickRef.current(id);
// 每个节点 → 它的“底层全部节点”id 列表(点击 → 高亮整簇);图与目录共用同一份。
const descRef = useRef<Map<any, any>>(new Map());
useEffect(() => {
const m = new Map<any, any>();
tree.forEach((t) => {
m.set(t.root.id, clusterIds(t));
t.subs.forEach((s) => {
const obj = ontology.objects.find((o) => o.id === s.id);
const refT = (obj?.relationships || []).map((r) => r.target_object_id).filter((id) => id && id !== t.root.id);
const refNodes = (obj?.relationships || []).filter((r) => r.fk_field).map((r) => refNodeId(s.id, r));
m.set(s.id, [s.id, ...attrNodeIds(s), ...refT, ...refNodes]);
});
t.behaviors.forEach(({ b, rules, events }) => {
m.set(b.id, [b.id, ...rules.map((r) => r.id), ...events.map((e) => e.id)]);
rules.forEach((r) => m.has(r.id) || m.set(r.id, [r.id]));
events.forEach((e) => m.has(e.id) || m.set(e.id, [e.id]));
});
});
const walk = (n: NavNode) => {
const ids: any[] = [];
const w2 = (node: NavNode) => { ids.push(`nav__${node.id}`); if (node.link && byRoot[node.link]) ids.push(...clusterIds(byRoot[node.link])); (node.children || []).forEach(w2); };
w2(n);
m.set(`nav__${n.id}`, ids);
(n.children || []).forEach(walk);
};
navs.forEach(walk);
descRef.current = m;
}, [ontology, tree, navs, byRoot]);
// 目录折叠只作用于目录本身(展开/合并树行),不再隐藏图上节点;
// 图的聚焦改由“点击任意节点 → 高亮它旗下的全部底层节点”实现(见 descRef / highlightById)。
const dirHidden = useMemo(() => new Set(), []);
useEffect(() => {
const cy = cytoscape({
container: boxRef.current,
elements: buildElements(ontology),
style: STYLE,
minZoom: 0.2, maxZoom: 4, wheelSensitivity: 0.25,
});
cyRef.current = cy;
// 布局在挂载后(容器与节点尺寸就绪)才跑,避免首帧节点重叠/漏画
const LAYOUT: any = { name: 'fcose', quality: 'proof', randomize: true, animate: false, fit: true, padding: 45, nodeSeparation: 110, idealEdgeLength: 120, nodeRepulsion: 8000, gravity: 0.28, packComponents: true, nestingFactor: 0.6 };
const runLayout = () => { cy.resize(); cy.layout(LAYOUT).run(); };
requestAnimationFrame(runLayout);
cy.on('layoutstop', () => { applyFilter(cy, filterRef.current.hiddenTypes, filterRef.current.hideOrphans, filterRef.current.dirHidden); });
const ro = new ResizeObserver(() => cy.resize());
if (boxRef.current) ro.observe(boxRef.current);
const place = () => {
const n = activeRef.current, el = tipRef.current;
if (!n || !el || !boxRef.current) return;
const p = n.renderedPosition();
const flip = p.x > boxRef.current.clientWidth * 0.58;
el.style.left = `${flip ? p.x - 300 : p.x + 30}px`;
el.style.top = `${Math.max(8, p.y - 12)}px`;
};
const showTip = (n: any) => { activeRef.current = n; setTip(describe(ontology, n.id())); requestAnimationFrame(place); };
const hideTip = () => { activeRef.current = null; setTip(null); };
// 高亮一组节点(整簇):淡化其余,连线仅保留簇内。
const highlightCluster = (idsArr: any[], focusId: string | null, doFit?: boolean) => {
const eles = cy.collection();
idsArr.forEach((id) => { const n = cy.$id(id); if (n && n.nonempty() && n.style('display') !== 'none') eles.merge(n); });
if (eles.empty()) return;
selRef.current = true;
cy.elements().removeClass('faded hl');
cy.elements().addClass('faded');
eles.removeClass('faded');
const inner = eles.edgesWith(eles);
inner.removeClass('faded').addClass('hl');
eles.addClass('hl');
setSelId(focusId || null);
hideTip();
if (doFit && eles.length > 1) cy.animate({ fit: { eles: eles.union(inner), padding: 60 }, duration: 260 });
else if (focusId) { const f = cy.$id(focusId); if (f.nonempty()) cy.animate({ center: { eles: f }, duration: 220 }); }
};
// 任意节点点击(图内或目录)→ 高亮它的“底层全部节点”,而非仅相邻节点
const highlightById = (id: string, doFit?: boolean) => highlightCluster(descRef.current.get(id) || [id], id, doFit);
cy.on('tap', 'node', (evt: any) => highlightById(evt.target.id(), true));
cy.on('tap', (evt: any) => { if (evt.target === cy) { selRef.current = false; cy.elements().removeClass('faded hl'); setSelId(null); hideTip(); } });
pickRef.current = (id: string) => highlightById(id, true);
cy.on('mouseover', 'node', (evt: any) => { if (!selRef.current) showTip(evt.target); });
cy.on('mouseout', 'node', () => { if (!selRef.current) hideTip(); });
cy.on('render', place);
return () => { ro.disconnect(); cy.destroy(); };
}, [ontology]);
// 过滤变化时重新应用(类型筛选 / 孤儿 / 目录折叠)
useEffect(() => {
filterRef.current = { hiddenTypes, hideOrphans, dirHidden };
if (cyRef.current) applyFilter(cyRef.current, hiddenTypes, hideOrphans, dirHidden);
}, [hiddenTypes, hideOrphans, dirHidden]);
const zoom = (f: number) => { const cy = cyRef.current; if (cy) cy.zoom({ level: cy.zoom() * f, renderedPosition: { x: cy.width() / 2, y: cy.height() / 2 } }); };
const fit = () => { const cy = cyRef.current; if (cy) { const v = cy.elements(':visible'); cy.fit(v.length ? v : undefined, 40); } };
const TYPES: [string, string][] = [['entity', '实体'], ['identifier', '标识'], ['attribute', '属性'], ['behavior', '行为'], ['rule', '规则'], ['event', '事件'], ['nav', '目录']];
const toggleType = (t: string) => setHiddenTypes((h) => ({ ...h, [t]: !h[t] }));
const btn: React.CSSProperties = { width: 30, height: 30, borderRadius: 8, border: '1px solid #e6e6e6', background: '#fff', color: '#52525b', fontSize: 16, cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center' };
const toggleSet = (setter: React.Dispatch<React.SetStateAction<Set<string>>>) => (id: string) => setter((s) => { const n = new Set(s); if (n.has(id)) n.delete(id); else n.add(id); return n; });
const toggleNav = toggleSet(setClosedNavs);
const toggleRoot = toggleSet(setClosedRoots);
const toggleBeh = toggleSet(setOpenBehs);
const allNavIds = useMemo(() => { const s: string[] = []; const walk = (n: NavNode) => { s.push(n.id); (n.children || []).forEach(walk); }; navs.forEach(walk); return s; }, [navs]);
const expandAll = () => { setClosedNavs(new Set()); setClosedRoots(new Set()); setOpenBehs(new Set(ontology.behaviors.map((b) => b.id))); };
const collapseAll = () => { setClosedNavs(new Set(allNavIds)); setClosedRoots(new Set(tree.map((t) => t.root.id))); setOpenBehs(new Set()); };
// ---- 目录渲染(树线样式) ----
// 一层缩进容器,画竖直引导线
const nest = (children: React.ReactNode) => (
<div style={{ marginLeft: 9, paddingLeft: 7, borderLeft: '1px solid #e8e8ea' }}>{children}</div>
);
// 顶层模块标题:点标题=图上高亮整簇;点三角=展开/合并目录
const moduleHeader = (label: string, open: boolean, onToggle: () => void, onPick: () => void, note: any) => (
<div onClick={onPick} style={{ display: 'flex', alignItems: 'center', gap: 7, cursor: 'pointer', userSelect: 'none', padding: '6px 8px', margin: '3px 0 1px', borderRadius: 6, fontSize: 13, fontWeight: 700, color: '#27272a' }}>
<span onClick={(e) => { e.stopPropagation(); onToggle(); }} style={{ width: 12, textAlign: 'center', color: '#9a9aa0', fontSize: 10, cursor: 'pointer' }}>{open ? '▾' : '▸'}</span>
<span style={{ flex: 1, minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{label}</span>
{note && <span style={{ fontSize: 10, color: '#b4b4ba', fontWeight: 400 }}>{note}</span>}
</div>
);
// 聚合内部:子实体 / 引用 / 行为(→ 规则、事件),不含根行(根由上层承载)。
// 这些是具体节点,点击走 pick():居中并高亮邻域(看单节点关系)。
const renderAggInner = (t: any) => (
<>
{t.subs.length > 0 && <GroupLabel text="子实体" count={t.subs.length} />}
{t.subs.map((s: any) => <Row key={s.id} type="entity" label={s.name} selected={selId === s.id} onPick={() => pick(s.id)} />)}
{t.refs.length > 0 && <GroupLabel text="引用" count={t.refs.length} />}
{t.refs.map((r: any) => <Row key={r.key} type="entity" label={r.label} selected={selId === r.target} onPick={() => pick(r.target)} />)}
{t.behaviors.length > 0 && <GroupLabel text="行为" count={t.behaviors.length} />}
{t.behaviors.map(({ b, rules, events }: any) => {
const kids = rules.length + events.length;
const bOpen = openBehs.has(b.id);
return (
<React.Fragment key={b.id}>
<Row type="behavior" label={b.name} chev={kids ? (bOpen ? 'open' : 'closed') : null} onChev={kids ? () => toggleBeh(b.id) : null} selected={selId === b.id} onPick={() => pick(b.id)} />
{bOpen && nest(
<>
{rules.map((r: any) => <Row key={r.id} type="rule" label={r.name} selected={selId === r.id} onPick={() => pick(r.id)} />)}
{events.map((e: any) => <Row key={e.id} type="event" label={e.name} selected={selId === e.id} onPick={() => pick(e.id)} />)}
</>
)}
</React.Fragment>
);
})}
</>
);
// 聚合根实体行 + 内部。根实体(如 订单)在图里是独立节点,目录里也单列一行,
// 作为叶子目录(如 销售订单明细)的子节点,与图完全对齐。
const renderAggRootAndInner = (t: any) => {
const open = !closedRoots.has(t.root.id);
return (
<>
<Row type="entity" label={t.root.name} bold note={`${t.subs.length + t.refs.length}子·${t.behaviors.length}行为`}
chev={open ? 'open' : 'closed'} onChev={() => toggleRoot(t.root.id)}
selected={selId === t.root.id} onPick={() => pick(t.root.id)} />
{open && nest(renderAggInner(t))}
</>
);
};
// 非顶层导航节点:点标题=高亮该目录旗下全部底层节点;点三角=展开/合并目录
const renderNav = (n: NavNode) => {
const open = !closedNavs.has(n.id);
const t = n.link ? byRoot[n.link] : null;
const label = n.zh || n.name || n.id;
return (
<React.Fragment key={n.id}>
<Row type="nav" label={label} bold
note={t ? `${t.subs.length + t.refs.length}子·${t.behaviors.length}行为` : null}
chev={open ? 'open' : 'closed'} onChev={() => toggleNav(n.id)}
selected={selId === `nav__${n.id}`} onPick={() => pick(`nav__${n.id}`)} />
{open && nest(t ? renderAggRootAndInner(t) : (n.children || []).map(renderNav))}
</React.Fragment>
);
};
// 顶层模块(M3 第一层)
const renderTopModule = (n: NavNode) => {
const open = !closedNavs.has(n.id);
const t = n.link ? byRoot[n.link] : null;
const label = n.zh || n.name || n.id;
const note = t ? `${t.subs.length + t.refs.length}子·${t.behaviors.length}行为` : null;
return (
<React.Fragment key={n.id}>
{moduleHeader(label, open, () => toggleNav(n.id), () => pick(`nav__${n.id}`), note)}
{open && nest(t ? renderAggRootAndInner(t) : (n.children || []).map(renderNav))}
</React.Fragment>
);
};
// 顶层聚合(无 M3 导航 / 未入目录时的兜底)
const renderAggTop = (t: any) => (
<div key={t.root.id} style={{ marginBottom: 2 }}>{renderAggRootAndInner(t)}</div>
);
const dirLink: React.CSSProperties = { cursor: 'pointer', color: '#6b6b72', userSelect: 'none' };
const directory = (
<div style={{ padding: '2px 4px 12px' }}>
<div style={{ display: 'flex', gap: 12, padding: '2px 8px 8px', fontSize: 11 }}>
<span onClick={expandAll} style={dirLink}>全部展开</span>
<span onClick={collapseAll} style={dirLink}>全部合并</span>
</div>
{navs.length ? (
<>
{navs.map(renderTopModule)}
{restTree.length > 0 && <GroupLabel text="未入目录" count={restTree.length} />}
{restTree.map(renderAggTop)}
</>
) : tree.map(renderAggTop)}
{others.length > 0 && <GroupLabel text="未归组" count={others.length} />}
{others.map((o) => <Row key={o.id} type={o.type} label={o.label} selected={selId === o.id} onPick={() => pick(o.id)} />)}
</div>
);
return (
<div style={{ position: 'absolute', inset: 0, display: 'flex', overflow: 'hidden' }}>
{/* 左侧目录(内置为组件自身的左栏,不再 portal 到外部侧边栏) */}
<div style={{ width: 260, flex: 'none', overflow: 'auto', borderRight: '1px solid #ececec', background: '#fff' }}>
{directory}
</div>
{/* 右侧:本体网络图 + 悬浮控件 */}
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
<div ref={boxRef} style={{ position: 'absolute', inset: 0, background: 'radial-gradient(circle at 55% 42%,#fdfdfe,#f6f7f9)' }} />
{/* 筛选面板:按类型显示/隐藏 + 隐藏孤儿节点 */}
<div style={{ position: 'absolute', top: 12, left: 14, width: 236, zIndex: 4 }}>
<div style={{ background: '#fff', border: '1px solid #ececec', borderRadius: 10, padding: '8px 10px', boxShadow: '0 1px 8px rgba(0,0,0,.06)' }}>
<div style={{ display: 'flex', gap: 6, alignItems: 'center', flexWrap: 'wrap' }}>
<span style={{ fontSize: 11, color: '#a6a6ac' }}>显示</span>
{TYPES.map(([t, label]) => {
const hid = !!hiddenTypes[t]; const c = COL[t];
return (
<span key={t} onClick={() => toggleType(t)} title={hid ? '点击显示' : '点击隐藏'}
style={{ cursor: 'pointer', fontSize: 11.5, padding: '3px 9px', borderRadius: 12, userSelect: 'none', border: `1px solid ${hid ? '#e6e6e6' : c[1]}`, background: hid ? '#f4f4f5' : c[0], color: hid ? '#b4b4ba' : c[2], textDecoration: hid ? 'line-through' : 'none' }}>{label}</span>
);
})}
</div>
<label style={{ display: 'flex', alignItems: 'center', gap: 6, marginTop: 7, fontSize: 12, color: '#52525b', cursor: 'pointer', userSelect: 'none' }}>
<input type="checkbox" checked={hideOrphans} onChange={(e) => setHideOrphans(e.target.checked)} style={{ cursor: 'pointer' }} /> 隐藏孤儿节点
</label>
</div>
</div>
<div style={{ position: 'absolute', bottom: 16, left: 14, fontSize: 11, color: '#c4c4cc', pointerEvents: 'none' }}>滚轮缩放 · 拖拽平移 · 单击节点看关系</div>
<div style={{ position: 'absolute', top: 12, right: 14, display: 'flex', flexDirection: 'column', gap: 6 }}>
<div style={btn} onClick={() => zoom(1.25)}>+</div>
<div style={btn} onClick={() => zoom(1 / 1.25)}>-</div>
<div style={{ ...btn, fontSize: 13 }} onClick={fit} title="适应画布">⤢</div>
</div>
<div ref={tipRef} style={{ position: 'absolute', width: 282, background: '#fff', border: '1px solid #ececec', borderRadius: 12, padding: '12px 14px', boxShadow: '0 6px 24px rgba(0,0,0,.12)', pointerEvents: 'none', display: tip ? 'block' : 'none', zIndex: 5 }}>
{tip && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 10.5, fontWeight: 600, color: '#fff', background: COL[tip.type][1], padding: '2px 8px', borderRadius: 5 }}>{TYPE_CN[tip.type]}</span>
<span style={{ fontSize: 14.5, fontWeight: 700 }}>{tip.title}</span>
</div>
{tip.lines.map((l: any, i: number) => (<div key={i} style={{ fontSize: 12, color: '#52525b', lineHeight: 1.65 }}>{l}</div>))}
</>
)}
</div>
</div>
</div>
);
}