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>
  );
}