knowledge-mindmap.blade.php 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669
  1. <!doctype html>
  2. <html lang="zh-CN">
  3. <head>
  4. <meta charset="UTF-8">
  5. <meta name="viewport" content="width=device-width, initial-scale=1.0">
  6. <title>知识图谱脑图(公开查看)</title>
  7. <link rel="preconnect" href="https://fonts.googleapis.com">
  8. <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
  9. <link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;700&display=swap" rel="stylesheet">
  10. <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
  11. <script src="https://gw.alipayobjects.com/os/lib/antv/g6/4.8.24/dist/g6.min.js"></script>
  12. <style>
  13. :root {
  14. color-scheme: light;
  15. }
  16. body {
  17. margin: 0;
  18. font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
  19. background: radial-gradient(circle at 10% 20%, #e0f2fe 0, transparent 20%), radial-gradient(circle at 90% 10%, #fce7f3 0, transparent 18%), #f8fafc;
  20. color: #0f172a;
  21. }
  22. .page {
  23. max-width: 1400px;
  24. margin: 0 auto;
  25. padding: 32px 20px 40px;
  26. }
  27. .card {
  28. background: white;
  29. border: 1px solid #e2e8f0;
  30. border-radius: 14px;
  31. box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
  32. padding: 18px 20px;
  33. }
  34. .stat {
  35. display: inline-flex;
  36. align-items: center;
  37. gap: 8px;
  38. padding: 8px 12px;
  39. border-radius: 10px;
  40. background: #f8fafc;
  41. border: 1px dashed #e2e8f0;
  42. font-size: 12px;
  43. color: #475569;
  44. }
  45. #knowledge-mindmap {
  46. width: 100%;
  47. min-height: 760px;
  48. height: 78vh;
  49. border: 1px solid #e2e8f0;
  50. border-radius: 14px;
  51. background: white;
  52. }
  53. </style>
  54. </head>
  55. <body>
  56. <div class="page" x-data="knowledgeMindmap()" x-init="initMindmap()">
  57. <div class="card" style="margin-bottom: 16px;">
  58. <div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px;">
  59. <div style="max-width: 780px;">
  60. <div style="font-size: 20px; font-weight: 700; color: #0f172a;">初中数学知识图谱 · 思维导图</div>
  61. <p style="margin: 8px 0 6px; font-size: 13px; color: #64748b; line-height: 1.5;">
  62. tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠,线条带方向。
  63. </p>
  64. <div style="display: flex; gap: 10px; flex-wrap: wrap;">
  65. <span class="stat">节点总数:<span x-text="stats.nodes"></span></span>
  66. <span class="stat">跨边数量:<span x-text="stats.extraEdges"></span></span>
  67. </div>
  68. </div>
  69. <div style="display: flex; gap: 12px; flex-wrap: wrap; align-items: center; font-size: 12px; color: #475569;">
  70. <span style="display:inline-flex;align-items:center;gap:6px;">
  71. <span style="display:block;width:16px;height:6px;border-radius:999px;background:#2563eb;"></span> 前置
  72. </span>
  73. <span style="display:inline-flex;align-items:center;gap:6px;">
  74. <span style="display:block;width:16px;height:6px;border-radius:999px;background:#dc2626;"></span> 后继
  75. </span>
  76. <span style="display:inline-flex;align-items:center;gap:6px;">
  77. <span style="display:block;width:16px;height:6px;border-radius:999px;border:2px dashed #64748b;"></span> 兄弟
  78. </span>
  79. <span style="display:inline-flex;align-items:center;gap:6px;">
  80. <span style="display:block;width:16px;height:6px;border-radius:999px;background:#fcd34d;"></span> 联合
  81. </span>
  82. </div>
  83. </div>
  84. </div>
  85. <div id="knowledge-mindmap" aria-label="知识图谱脑图"></div>
  86. </div>
  87. <script>
  88. window.knowledgeMindmap = () => ({
  89. isTreeGraph: true,
  90. graph: null,
  91. treeData: null,
  92. relationEdges: [],
  93. stats: { nodes: 0, extraEdges: 0 },
  94. arrow(w = 12, h = 14, r = 5) {
  95. if (window.G6?.Arrow?.triangle) {
  96. return G6.Arrow.triangle(w, h, r);
  97. }
  98. return [
  99. ['M', 0, 0],
  100. ['L', w, h / 2],
  101. ['L', 0, h],
  102. ['Z'],
  103. ];
  104. },
  105. // ... (omitted for brevity, but I need to target the right place)
  106. levelStyles: [
  107. {
  108. fill: '#0ea5e9',
  109. stroke: '#0369a1',
  110. labelColor: '#0f172a',
  111. fontSize: 22, // Increased from 17
  112. fontWeight: 700,
  113. size: 40, // Increased from 34
  114. lineWidth: 4, // Explicitly set thicker line
  115. },
  116. {
  117. fill: '#e0f2fe',
  118. stroke: '#38bdf8',
  119. labelColor: '#0f172a',
  120. fontSize: 20, // Increased from 16
  121. fontWeight: 700,
  122. size: 36, // Increased from 30
  123. lineWidth: 4,
  124. },
  125. {
  126. fill: '#f1f5f9',
  127. stroke: '#cbd5e1',
  128. labelColor: '#0f172a',
  129. fontSize: 18, // Increased from 14
  130. fontWeight: 600,
  131. size: 32, // Increased from 26
  132. lineWidth: 3.5,
  133. },
  134. ],
  135. relationStyles: {
  136. prerequisite: {
  137. type: 'quadratic',
  138. curveOffset: 60,
  139. style: {
  140. stroke: '#2563eb',
  141. lineWidth: 5, // Increased from 3.4
  142. lineDash: [8, 6],
  143. endArrow: {
  144. path: null,
  145. fill: '#2563eb',
  146. d: 16, // Increased arrow size
  147. },
  148. startArrow: false,
  149. shadowBlur: 0,
  150. shadowColor: undefined,
  151. },
  152. label: '前置',
  153. },
  154. successor: {
  155. type: 'quadratic',
  156. curveOffset: 60,
  157. style: {
  158. stroke: '#dc2626',
  159. lineWidth: 5, // Increased from 3.4
  160. lineDash: [8, 6],
  161. endArrow: {
  162. path: null,
  163. fill: '#dc2626',
  164. d: 16,
  165. },
  166. startArrow: false,
  167. shadowBlur: 0,
  168. shadowColor: undefined,
  169. },
  170. label: '后继',
  171. },
  172. sibling: {
  173. type: 'quadratic',
  174. curveOffset: 50,
  175. style: {
  176. stroke: '#64748b',
  177. lineDash: [6, 6],
  178. lineWidth: 4, // Increased from 3
  179. endArrow: {
  180. path: null,
  181. fill: '#64748b',
  182. d: 14,
  183. },
  184. shadowBlur: 0,
  185. shadowColor: undefined,
  186. },
  187. label: '兄弟',
  188. },
  189. joint: {
  190. type: 'quadratic',
  191. curveOffset: 50,
  192. style: {
  193. stroke: '#fcd34d',
  194. lineWidth: 4, // Increased from 3
  195. lineDash: [10, 8],
  196. endArrow: {
  197. path: null,
  198. fill: '#fbbf24',
  199. d: 14,
  200. },
  201. shadowBlur: 0,
  202. shadowColor: undefined,
  203. },
  204. label: '联合',
  205. },
  206. default: {
  207. type: 'quadratic',
  208. curveOffset: 50,
  209. style: {
  210. stroke: '#94a3b8',
  211. lineWidth: 4, // Increased from 3
  212. lineDash: [10, 8],
  213. endArrow: {
  214. path: null,
  215. fill: '#94a3b8',
  216. d: 14,
  217. },
  218. shadowBlur: 0,
  219. shadowColor: undefined,
  220. },
  221. label: '',
  222. },
  223. },
  224. async initMindmap() {
  225. try {
  226. if (this.$nextTick) {
  227. await this.$nextTick();
  228. }
  229. if (!window.G6) {
  230. console.error('G6 未加载');
  231. return;
  232. }
  233. // 补充箭头路径
  234. Object.keys(this.relationStyles).forEach((key) => {
  235. const rel = this.relationStyles[key];
  236. if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
  237. rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 14, (rel.style.endArrow.d || 14) + 4, 6);
  238. }
  239. });
  240. await this.loadData();
  241. this.applyInitialCollapse(this.treeData);
  242. this.renderGraph();
  243. window.addEventListener('resize', () => this.resizeGraph());
  244. } catch (err) {
  245. console.error('初始化思维导图失败', err);
  246. const container = document.getElementById('knowledge-mindmap');
  247. if (container) {
  248. container.innerHTML = '<div style="padding:20px;color:#dc2626;">图数据加载失败,请检查 /data/tree.json 与 /data/edges.json 是否可访问。</div>';
  249. }
  250. }
  251. },
  252. async loadData() {
  253. const treeUrl = '/data/tree.json';
  254. const edgeUrl = '/data/edges.json';
  255. const [treeResp, edgesResp] = await Promise.all([fetch(treeUrl), fetch(edgeUrl)]);
  256. if (!treeResp.ok || !edgesResp.ok) {
  257. throw new Error(`数据加载失败 tree:${treeResp.status} edge:${edgesResp.status}`);
  258. }
  259. const rawTree = await treeResp.json();
  260. const edges = await edgesResp.json();
  261. const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
  262. this.treeData = this.transformNode(rawTree);
  263. this.relationEdges = this.normalizeEdges(rawEdges);
  264. if (!this.treeData) {
  265. throw new Error('tree.json 为空或格式不正确');
  266. }
  267. this.stats = {
  268. nodes: this.countNodes(this.treeData),
  269. extraEdges: this.relationEdges.length,
  270. };
  271. },
  272. transformNode(node, depth = 0) {
  273. if (!node) return null;
  274. const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
  275. const label = node.name || node.label || node.code || node.id || '未命名节点';
  276. const meta = {
  277. code: node.code || node.id || '',
  278. name: label,
  279. direct_score: node.direct_score || [],
  280. related_score: node.related_score || [],
  281. skills: node.skills || [],
  282. };
  283. return {
  284. id,
  285. label,
  286. meta,
  287. depth,
  288. children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
  289. };
  290. },
  291. applyInitialCollapse(node, depth = 0) {
  292. if (!node) return;
  293. if (depth >= 2 && node.children.length > 0) {
  294. node.collapsed = true;
  295. }
  296. node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
  297. },
  298. countNodes(node) {
  299. if (!node) return 0;
  300. return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
  301. },
  302. normalizeEdges(rawEdges) {
  303. const seen = new Set();
  304. const normalized = [];
  305. (rawEdges || []).forEach((edge, index) => {
  306. if (!edge?.source || !edge?.target) return;
  307. const key = `${edge.source}-${edge.target}-${edge.type}`;
  308. if (seen.has(key)) return;
  309. seen.add(key);
  310. const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
  311. normalized.push({
  312. id: `rel-${index}-${edge.source}-${edge.target}`,
  313. source: edge.source,
  314. target: edge.target,
  315. type: relationStyle.type || 'quadratic',
  316. curveOffset: relationStyle.curveOffset || 50,
  317. style: relationStyle.style,
  318. label: relationStyle.label,
  319. comment: edge.comment || edge.note || '',
  320. });
  321. });
  322. return normalized;
  323. },
  324. renderGraph(containerEl = null) {
  325. if (!this.treeData) return;
  326. const container = containerEl || document.getElementById('knowledge-mindmap');
  327. if (!container) {
  328. console.error('容器未找到');
  329. return;
  330. }
  331. const ensuredId = container.id || 'knowledge-mindmap';
  332. if (!container.id) {
  333. container.id = ensuredId;
  334. }
  335. const bounds = container.getBoundingClientRect();
  336. const width = Math.max(bounds.width || container.clientWidth || 0, 600);
  337. const height = Math.max(bounds.height || container.clientHeight || 0, 600);
  338. const tooltipEl = document.createElement('div');
  339. tooltipEl.style.position = 'fixed';
  340. tooltipEl.style.pointerEvents = 'none';
  341. tooltipEl.style.zIndex = '9999';
  342. tooltipEl.style.display = 'none';
  343. document.body.appendChild(tooltipEl);
  344. const showTooltip = (html, x, y) => {
  345. tooltipEl.innerHTML = html;
  346. tooltipEl.style.left = `${x + 12}px`;
  347. tooltipEl.style.top = `${y + 12}px`;
  348. tooltipEl.style.display = 'block';
  349. };
  350. const hideTooltip = () => {
  351. tooltipEl.style.display = 'none';
  352. tooltipEl.innerHTML = '';
  353. };
  354. const G6Lib = window.G6?.default || window.G6;
  355. const TreeGraphClass = G6Lib?.TreeGraph || null;
  356. if (!TreeGraphClass) {
  357. console.error('G6 TreeGraph 不可用');
  358. return;
  359. }
  360. const graphData = this.decorateTree(this.treeData);
  361. const graphConfig = {
  362. container: ensuredId,
  363. width,
  364. height,
  365. data: graphData,
  366. linkCenter: true,
  367. modes: {
  368. default: [
  369. 'drag-canvas',
  370. 'zoom-canvas',
  371. {
  372. type: 'collapse-expand',
  373. trigger: 'click',
  374. onChange: function onChange(item, collapsed) {
  375. if (!item) return;
  376. item.getModel().collapsed = collapsed;
  377. return true;
  378. },
  379. },
  380. ],
  381. },
  382. layout: {
  383. type: 'mindmap',
  384. direction: 'H',
  385. getHeight: () => 40, // Increased from 32
  386. getWidth: () => 140,
  387. getVGap: () => 32,
  388. getHGap: () => 60, // Decreased from 110
  389. },
  390. defaultNode: {
  391. size: 26, // Increased from 22
  392. style: {
  393. stroke: '#94a3b8',
  394. fill: '#fff',
  395. radius: 4,
  396. shadowColor: undefined,
  397. shadowBlur: 0,
  398. lineWidth: 4, // Increased from 3
  399. },
  400. labelCfg: {
  401. style: {
  402. fontSize: 16, // Increased from 13
  403. fill: '#0f172a',
  404. fontWeight: 600, // Increased weight
  405. },
  406. position: 'right',
  407. offset: 14,
  408. },
  409. },
  410. defaultEdge: {
  411. type: 'cubic-horizontal',
  412. style: {
  413. stroke: '#cbd5f5',
  414. lineWidth: 4, // Increased from 3
  415. shadowBlur: 0,
  416. shadowColor: undefined,
  417. },
  418. },
  419. nodeStateStyles: {
  420. selected: {
  421. lineWidth: 4.5, // Increased
  422. stroke: '#2563eb',
  423. fill: '#e0f2fe',
  424. },
  425. },
  426. edgeStateStyles: {
  427. highlight: {
  428. lineWidth: 5, // Increased
  429. stroke: '#fb923c',
  430. },
  431. },
  432. plugins: [],
  433. };
  434. try {
  435. this.graph = new TreeGraphClass(graphConfig);
  436. if (typeof this.graph.data === 'function') {
  437. this.graph.data(graphData);
  438. } else if (typeof this.graph.changeData === 'function') {
  439. this.graph.changeData(graphData);
  440. }
  441. if (typeof this.graph.render === 'function') {
  442. this.graph.render();
  443. }
  444. } catch (e) {
  445. console.error('创建图失败', e);
  446. return;
  447. }
  448. this.drawRelationEdges();
  449. this.graph.fitView(24);
  450. this.bindEvents();
  451. // 手动 tooltip
  452. const handleNodeEnter = (evt) => {
  453. const { clientX, clientY } = evt;
  454. showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
  455. };
  456. const handleEdgeEnter = (evt) => {
  457. const model = evt?.item?.getModel() || {};
  458. const relation = model.label || '关联关系';
  459. const text = `${model.source || ''} → ${model.target || ''}`;
  460. const comment = model.comment ? `<div style="font-size:11px;color:#475569;margin-top:4px;white-space:pre-line;">备注:${model.comment}</div>` : '';
  461. const html = `
  462. <div style="border:1px solid #e2e8f0;border-radius:8px;background:white;padding:8px 10px;font-size:12px;color:#475569;box-shadow:0 10px 20px rgba(15,23,42,0.08);">
  463. <div style="font-weight:700;color:#0f172a;margin-bottom:4px;">${relation}</div>
  464. <div>${text}</div>
  465. ${comment}
  466. </div>
  467. `;
  468. const { clientX, clientY } = evt;
  469. showTooltip(html, clientX, clientY);
  470. };
  471. const handleLeave = () => hideTooltip();
  472. this.graph.on('node:mouseenter', handleNodeEnter);
  473. this.graph.on('node:mouseleave', handleLeave);
  474. this.graph.on('edge:mouseenter', handleEdgeEnter);
  475. this.graph.on('edge:mouseleave', handleLeave);
  476. // 监听布局变化,重新绘制关联线
  477. this.graph.on('afterlayout', () => {
  478. this.drawRelationEdges();
  479. });
  480. // 全量刷新,避免缩放重影
  481. const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
  482. if (canvas && typeof canvas.set === 'function') {
  483. canvas.set('localRefresh', false);
  484. const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
  485. if (ctx) {
  486. ctx.shadowColor = 'transparent';
  487. ctx.shadowBlur = 0;
  488. }
  489. }
  490. },
  491. decorateTree(node) {
  492. if (!node) return null;
  493. const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
  494. return {
  495. id: node.id,
  496. label: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
  497. meta: node.meta,
  498. collapsed: node.collapsed,
  499. depth: node.depth,
  500. size,
  501. style: nodeStyle,
  502. labelCfg,
  503. children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
  504. };
  505. },
  506. getNodeLevelStyle(depth = 0) {
  507. const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
  508. return {
  509. size: style.size || 26, // Increased default
  510. nodeStyle: {
  511. fill: style.fill || '#fff',
  512. stroke: style.stroke || '#cbd5f5',
  513. lineWidth: style.lineWidth || 4, // Increased default
  514. radius: 6,
  515. shadowColor: undefined,
  516. shadowBlur: 0,
  517. },
  518. labelCfg: {
  519. position: 'right',
  520. offset: 14, // Increased offset
  521. style: {
  522. fontSize: style.fontSize || 16, // Increased default
  523. fontWeight: style.fontWeight || 600,
  524. fill: style.labelColor || '#0f172a',
  525. },
  526. },
  527. };
  528. },
  529. drawRelationEdges() {
  530. if (!this.graph || !this.relationEdges.length) return;
  531. const canAddEdge = typeof this.graph.addEdge === 'function';
  532. const canAddItem = typeof this.graph.addItem === 'function';
  533. const buildModel = (edge, index) => {
  534. const style = { ...(edge.style || {}), lineAppendWidth: 14 };
  535. if (style.endArrow && !style.endArrow.path) {
  536. style.endArrow = {
  537. ...style.endArrow,
  538. path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
  539. };
  540. }
  541. return {
  542. id: `extra-${index}`,
  543. source: edge.source,
  544. target: edge.target,
  545. type: edge.type || 'quadratic',
  546. curveOffset: edge.curveOffset || 50,
  547. style,
  548. label: edge.label,
  549. comment: edge.comment || '',
  550. labelCfg: {
  551. autoRotate: true,
  552. style: {
  553. fill: '#475569',
  554. fontSize: 11,
  555. background: {
  556. fill: 'rgba(255,255,255,0.85)',
  557. padding: [2, 4],
  558. radius: 4,
  559. },
  560. },
  561. },
  562. };
  563. };
  564. if (this.isTreeGraph && (canAddEdge || canAddItem)) {
  565. this.relationEdges.forEach((edge, index) => {
  566. // 检查节点是否存在
  567. if (!this.graph.findById(edge.source) || !this.graph.findById(edge.target)) {
  568. return;
  569. }
  570. const model = buildModel(edge, index);
  571. // 防止重复添加
  572. if (this.graph.findById(model.id)) {
  573. return;
  574. }
  575. if (canAddEdge) {
  576. this.graph.addEdge(model);
  577. } else {
  578. this.graph.addItem('edge', model);
  579. }
  580. });
  581. if (typeof this.graph.paint === 'function') {
  582. this.graph.paint();
  583. }
  584. } else {
  585. const currentData = this.graph.save?.() || this.graphDataset || { nodes: [], edges: [] };
  586. const nodes = currentData.nodes || [];
  587. const mergedEdges = (currentData.edges || []).concat(
  588. this.relationEdges.map((edge, index) => buildModel(edge, index))
  589. );
  590. const merged = { nodes, edges: mergedEdges };
  591. if (typeof this.graph.changeData === 'function') {
  592. this.graph.changeData(merged);
  593. } else if (typeof this.graph.data === 'function' && typeof this.graph.render === 'function') {
  594. this.graph.data(merged);
  595. this.graph.render();
  596. }
  597. }
  598. },
  599. buildTooltip(model) {
  600. const meta = model?.meta;
  601. if (!meta) return '<div class="text-xs text-gray-600">无数据</div>';
  602. const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
  603. const skills = (meta.skills || [])
  604. .slice(0, 6)
  605. .map((skill) => `<li style="line-height:1.1;">${skill.trim()}</li>`)
  606. .join('') || '<li>暂无技能</li>';
  607. return `
  608. <div style="min-width:230px;max-width:340px;border:1px solid #e2e8f0;border-radius:8px;background:white;padding:10px;font-size:12px;color:#475569;box-shadow:0 10px 30px rgba(15,23,42,0.12);">
  609. <div style="font-size:14px;font-weight:700;color:#0f172a;margin-bottom:4px;">${meta.code || model.id} · ${meta.name}</div>
  610. <div style="display:flex;gap:12px;font-size:12px;">
  611. <span>直接:${range(meta.direct_score)}</span>
  612. <span>关联:${range(meta.related_score)}</span>
  613. </div>
  614. <div style="margin-top:8px;">
  615. <div style="font-weight:600;">技能要点</div>
  616. <ul style="padding-left:18px;margin:6px 0; list-style: disc; display: grid; gap: 4px;">
  617. ${skills}
  618. </ul>
  619. </div>
  620. </div>
  621. `;
  622. },
  623. bindEvents() {
  624. if (!this.graph) return;
  625. this.graph.on('node:click', (evt) => {
  626. const nodeId = evt?.item?.getID();
  627. if (nodeId) this.highlightEdges(nodeId);
  628. });
  629. this.graph.on('canvas:click', () => this.resetHighlight());
  630. },
  631. highlightEdges(nodeId) {
  632. this.graph.getNodes().forEach((node) => {
  633. this.graph.clearItemStates(node);
  634. if (node.getID() === nodeId) {
  635. this.graph.setItemState(node, 'selected', true);
  636. }
  637. });
  638. this.graph.getEdges().forEach((edge) => {
  639. const { source, target } = edge.getModel();
  640. const linked = source === nodeId || target === nodeId;
  641. if (linked) {
  642. this.graph.setItemState(edge, 'highlight', true);
  643. } else {
  644. this.graph.clearItemStates(edge);
  645. }
  646. });
  647. },
  648. resetHighlight() {
  649. if (!this.graph) return;
  650. this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
  651. this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
  652. },
  653. resizeGraph() {
  654. if (!this.graph) return;
  655. const container = document.getElementById('knowledge-mindmap');
  656. if (!container) return;
  657. this.graph.changeSize(container.clientWidth, container.clientHeight);
  658. this.graph.fitView(24);
  659. },
  660. });
  661. </script>
  662. </body>
  663. </html>