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 = { 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 = { 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(); const usedEvents = new Set(); 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 (
{ e.stopPropagation(); onChev(); } : undefined} style={{ width: 12, textAlign: 'center', color: '#b4b4ba', fontSize: 9, flex: 'none', cursor: onChev ? 'pointer' : 'default' }}> {chev === 'open' ? '▾' : chev === 'closed' ? '▸' : ''} {label}{alias && {alias}} {note && {note}}
); } const GroupLabel = ({ text, count }: any) => (
{text}{count != null ? ` · ${count}` : ''}
); // 按类型 + 孤儿 + 目录折叠 过滤:用 display:none(可逆),连线随端点自动隐藏。 // 孤儿 = 没有任何连到“类型可见”邻居的边(含 0 边的独立节点)。 // dirHidden = 目录里被折叠的聚合/导航分组所覆盖的全部节点 id。 function applyFilter(cy: any, hiddenTypes: Record, hideOrphans: boolean, dirHidden: Set = 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(null); const cyRef = useRef(null); const tipRef = useRef(null); const activeRef = useRef(null); const selRef = useRef(false); const [tip, setTip] = useState(null); const [hiddenTypes, setHiddenTypes] = useState>({ nav: true }); // 默认隐藏目录节点(可在筛选面板点「目录」显示) const [hideOrphans, setHideOrphans] = useState(false); const filterRef = useRef({ 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(); 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>(() => new Set()); // 折叠的导航节点(含叶子) const [closedRoots, setClosedRoots] = useState>(() => new Set()); // 无导航退化模式下折叠的聚合 const [openBehs, setOpenBehs] = useState>(() => new Set()); const [selId, setSelId] = useState(null); const pickRef = useRef<((id: string) => void) | null>(null); const pick = (id: string) => pickRef.current && pickRef.current(id); // 每个节点 → 它的“底层全部节点”id 列表(点击 → 高亮整簇);图与目录共用同一份。 const descRef = useRef>(new Map()); useEffect(() => { const m = new Map(); 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>>) => (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) => (
{children}
); // 顶层模块标题:点标题=图上高亮整簇;点三角=展开/合并目录 const moduleHeader = (label: string, open: boolean, onToggle: () => void, onPick: () => void, note: any) => (
{ e.stopPropagation(); onToggle(); }} style={{ width: 12, textAlign: 'center', color: '#9a9aa0', fontSize: 10, cursor: 'pointer' }}>{open ? '▾' : '▸'} {label} {note && {note}}
); // 聚合内部:子实体 / 引用 / 行为(→ 规则、事件),不含根行(根由上层承载)。 // 这些是具体节点,点击走 pick():居中并高亮邻域(看单节点关系)。 const renderAggInner = (t: any) => ( <> {t.subs.length > 0 && } {t.subs.map((s: any) => pick(s.id)} />)} {t.refs.length > 0 && } {t.refs.map((r: any) => pick(r.target)} />)} {t.behaviors.length > 0 && } {t.behaviors.map(({ b, rules, events }: any) => { const kids = rules.length + events.length; const bOpen = openBehs.has(b.id); return ( toggleBeh(b.id) : null} selected={selId === b.id} onPick={() => pick(b.id)} /> {bOpen && nest( <> {rules.map((r: any) => pick(r.id)} />)} {events.map((e: any) => pick(e.id)} />)} )} ); })} ); // 聚合根实体行 + 内部。根实体(如 订单)在图里是独立节点,目录里也单列一行, // 作为叶子目录(如 销售订单明细)的子节点,与图完全对齐。 const renderAggRootAndInner = (t: any) => { const open = !closedRoots.has(t.root.id); return ( <> 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 ( toggleNav(n.id)} selected={selId === `nav__${n.id}`} onPick={() => pick(`nav__${n.id}`)} /> {open && nest(t ? renderAggRootAndInner(t) : (n.children || []).map(renderNav))} ); }; // 顶层模块(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 ( {moduleHeader(label, open, () => toggleNav(n.id), () => pick(`nav__${n.id}`), note)} {open && nest(t ? renderAggRootAndInner(t) : (n.children || []).map(renderNav))} ); }; // 顶层聚合(无 M3 导航 / 未入目录时的兜底) const renderAggTop = (t: any) => (
{renderAggRootAndInner(t)}
); const dirLink: React.CSSProperties = { cursor: 'pointer', color: '#6b6b72', userSelect: 'none' }; const directory = (
全部展开 全部合并
{navs.length ? ( <> {navs.map(renderTopModule)} {restTree.length > 0 && } {restTree.map(renderAggTop)} ) : tree.map(renderAggTop)} {others.length > 0 && } {others.map((o) => pick(o.id)} />)}
); return (
{/* 左侧目录(内置为组件自身的左栏,不再 portal 到外部侧边栏) */}
{directory}
{/* 右侧:本体网络图 + 悬浮控件 */}
{/* 筛选面板:按类型显示/隐藏 + 隐藏孤儿节点 */}
显示 {TYPES.map(([t, label]) => { const hid = !!hiddenTypes[t]; const c = COL[t]; return ( 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} ); })}
滚轮缩放 · 拖拽平移 · 单击节点看关系
zoom(1.25)}>+
zoom(1 / 1.25)}>-
{tip && ( <>
{TYPE_CN[tip.type]} {tip.title}
{tip.lines.map((l: any, i: number) => (
{l}
))} )}
); }