knowledge-mindmap.blade.php 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568
  1. <x-filament::page>
  2. <div
  3. class="space-y-4"
  4. x-data="knowledgeMindmap()"
  5. x-init="initMindmap()"
  6. >
  7. <div class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
  8. <div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
  9. <div>
  10. <h2 class="text-lg font-semibold text-gray-900">初中数学知识图谱 · 思维导图</h2>
  11. <p class="text-sm text-gray-500">
  12. tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠并叠加前置/后继/兄弟/联合连线。
  13. </p>
  14. <div class="mt-2 flex gap-4 text-xs text-gray-500">
  15. <div>节点总数:<span x-text="stats.nodes"></span></div>
  16. <div>跨边数量:<span x-text="stats.extraEdges"></span></div>
  17. </div>
  18. </div>
  19. <div class="flex flex-wrap gap-3 text-xs text-gray-600">
  20. <span class="inline-flex items-center gap-1">
  21. <span class="h-2 w-4 rounded-full bg-blue-500"></span> 前置
  22. </span>
  23. <span class="inline-flex items-center gap-1">
  24. <span class="h-2 w-4 rounded-full bg-red-500"></span> 后继
  25. </span>
  26. <span class="inline-flex items-center gap-1">
  27. <span class="h-2 w-4 rounded-full border border-dashed border-gray-500"></span> 兄弟
  28. </span>
  29. <span class="inline-flex items-center gap-1">
  30. <span class="h-2 w-4 rounded-full bg-yellow-400"></span> 联合考查
  31. </span>
  32. </div>
  33. </div>
  34. </div>
  35. <div
  36. id="knowledge-mindmap"
  37. class="h-[80vh] min-h-[720px] w-full rounded-lg border border-gray-200 bg-white"
  38. ></div>
  39. </div>
  40. </x-filament::page>
  41. @push('scripts')
  42. <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
  43. <script>
  44. document.addEventListener('alpine:init', () => {
  45. window.knowledgeMindmap = () => ({
  46. graph: null,
  47. treeData: null,
  48. relationEdges: [],
  49. stats: { nodes: 0, extraEdges: 0 },
  50. arrow(w = 12, h = 14, r = 5) {
  51. if (window.G6?.Arrow?.triangle) {
  52. return G6.Arrow.triangle(w, h, r);
  53. }
  54. return [
  55. ['M', 0, 0],
  56. ['L', w, h / 2],
  57. ['L', 0, h],
  58. ['Z'],
  59. ];
  60. },
  61. levelStyles: [
  62. {
  63. fill: '#0ea5e9',
  64. stroke: '#0369a1',
  65. labelColor: '#0f172a',
  66. fontSize: 17,
  67. fontWeight: 700,
  68. size: 34,
  69. },
  70. {
  71. fill: '#e0f2fe',
  72. stroke: '#38bdf8',
  73. labelColor: '#0f172a',
  74. fontSize: 16,
  75. fontWeight: 700,
  76. size: 30,
  77. },
  78. {
  79. fill: '#f1f5f9',
  80. stroke: '#cbd5e1',
  81. labelColor: '#0f172a',
  82. fontSize: 14,
  83. fontWeight: 600,
  84. size: 26,
  85. },
  86. ],
  87. relationStyles: {
  88. prerequisite: {
  89. type: 'quadratic',
  90. curveOffset: 60,
  91. style: {
  92. stroke: '#2563eb',
  93. lineWidth: 3.4,
  94. lineDash: [8, 6],
  95. endArrow: {
  96. path: null,
  97. fill: '#2563eb',
  98. d: 12,
  99. },
  100. startArrow: false,
  101. },
  102. label: '前置',
  103. },
  104. successor: {
  105. type: 'quadratic',
  106. curveOffset: 60,
  107. style: {
  108. stroke: '#dc2626',
  109. lineWidth: 3.4,
  110. lineDash: [8, 6],
  111. endArrow: {
  112. path: null,
  113. fill: '#dc2626',
  114. d: 12,
  115. },
  116. startArrow: false,
  117. },
  118. label: '后继',
  119. },
  120. sibling: {
  121. type: 'quadratic',
  122. curveOffset: 50,
  123. style: {
  124. stroke: '#64748b',
  125. lineDash: [6, 6],
  126. lineWidth: 3,
  127. endArrow: {
  128. path: null,
  129. fill: '#64748b',
  130. d: 10,
  131. },
  132. },
  133. label: '兄弟',
  134. },
  135. joint: {
  136. type: 'quadratic',
  137. curveOffset: 50,
  138. style: {
  139. stroke: '#fcd34d',
  140. lineWidth: 3,
  141. lineDash: [10, 8],
  142. endArrow: {
  143. path: null,
  144. fill: '#fbbf24',
  145. d: 10,
  146. },
  147. },
  148. label: '联合',
  149. },
  150. default: {
  151. type: 'quadratic',
  152. curveOffset: 50,
  153. style: {
  154. stroke: '#94a3b8',
  155. lineWidth: 3,
  156. lineDash: [10, 8],
  157. endArrow: {
  158. path: null,
  159. fill: '#94a3b8',
  160. d: 10,
  161. },
  162. },
  163. label: '',
  164. },
  165. },
  166. async initMindmap() {
  167. try {
  168. if (this.$nextTick) {
  169. await this.$nextTick();
  170. }
  171. if (!window.G6) {
  172. console.error('G6 未加载');
  173. return;
  174. }
  175. Object.keys(this.relationStyles).forEach((key) => {
  176. const rel = this.relationStyles[key];
  177. if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
  178. rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
  179. }
  180. });
  181. await this.loadData();
  182. this.applyInitialCollapse(this.treeData);
  183. this.renderGraph();
  184. window.addEventListener('resize', () => this.resizeGraph());
  185. } catch (err) {
  186. console.error('初始化思维导图失败', err);
  187. }
  188. },
  189. async loadData() {
  190. const [treeResp, edgesResp] = await Promise.all([
  191. fetch('/data/tree.json'),
  192. fetch('/data/edges.json'),
  193. ]);
  194. const rawTree = await treeResp.json();
  195. const edges = await edgesResp.json();
  196. const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
  197. this.treeData = this.transformNode(rawTree);
  198. this.relationEdges = this.normalizeEdges(rawEdges);
  199. this.stats = {
  200. nodes: this.countNodes(this.treeData),
  201. extraEdges: this.relationEdges.length,
  202. };
  203. },
  204. transformNode(node, depth = 0) {
  205. if (!node) {
  206. return null;
  207. }
  208. const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`;
  209. const label = node.name || node.label || node.code || node.id || '未命名节点';
  210. const meta = {
  211. code: node.code || node.id || '',
  212. name: label,
  213. direct_score: node.direct_score || [],
  214. related_score: node.related_score || [],
  215. skills: node.skills || [],
  216. };
  217. return {
  218. id,
  219. label,
  220. meta,
  221. depth,
  222. children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
  223. };
  224. },
  225. applyInitialCollapse(node, depth = 0) {
  226. if (!node) {
  227. return;
  228. }
  229. if (depth >= 2 && node.children.length > 0) {
  230. node.collapsed = true;
  231. }
  232. node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
  233. },
  234. countNodes(node) {
  235. if (!node) {
  236. return 0;
  237. }
  238. return 1 + node.children.reduce((sum, child) => sum + this.countNodes(child), 0);
  239. },
  240. normalizeEdges(rawEdges) {
  241. const seen = new Set();
  242. const normalized = [];
  243. (rawEdges || []).forEach((edge, index) => {
  244. if (!edge?.source || !edge?.target) {
  245. return;
  246. }
  247. const key = `${edge.source}-${edge.target}-${edge.type}`;
  248. if (seen.has(key)) {
  249. return;
  250. }
  251. seen.add(key);
  252. const relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
  253. normalized.push({
  254. id: `rel-${index}-${edge.source}-${edge.target}`,
  255. source: edge.source,
  256. target: edge.target,
  257. type: relationStyle.type || 'quadratic',
  258. curveOffset: relationStyle.curveOffset || 50,
  259. style: relationStyle.style,
  260. label: relationStyle.label,
  261. comment: edge.comment || edge.note || '',
  262. });
  263. });
  264. return normalized;
  265. },
  266. renderGraph(containerEl = null) {
  267. if (!this.treeData) {
  268. return;
  269. }
  270. const container = containerEl || document.getElementById('knowledge-mindmap');
  271. if (!container) {
  272. console.error('容器未找到');
  273. return;
  274. }
  275. const ensuredId = container.id || 'knowledge-mindmap';
  276. if (!container.id) {
  277. container.id = ensuredId;
  278. }
  279. const bounds = container.getBoundingClientRect();
  280. const width = Math.max(bounds.width, 600);
  281. const height = Math.max(bounds.height, 600);
  282. const tooltipEl = document.createElement('div');
  283. tooltipEl.className = 'fixed z-50 pointer-events-none hidden';
  284. document.body.appendChild(tooltipEl);
  285. const showTooltip = (html, x, y) => {
  286. tooltipEl.innerHTML = html;
  287. tooltipEl.style.left = `${x + 12}px`;
  288. tooltipEl.style.top = `${y + 12}px`;
  289. tooltipEl.classList.remove('hidden');
  290. };
  291. const hideTooltip = () => {
  292. tooltipEl.classList.add('hidden');
  293. tooltipEl.innerHTML = '';
  294. };
  295. const G6Lib = window.G6?.default || window.G6;
  296. const TreeGraphClass = G6Lib?.TreeGraph || null;
  297. if (!TreeGraphClass) {
  298. console.error('G6 TreeGraph 不可用');
  299. return;
  300. }
  301. const graphData = this.decorateTree(this.treeData);
  302. const graphConfig = {
  303. container: ensuredId,
  304. width,
  305. height,
  306. data: graphData,
  307. linkCenter: true,
  308. modes: {
  309. default: [
  310. 'drag-canvas',
  311. 'zoom-canvas',
  312. {
  313. type: 'collapse-expand',
  314. trigger: 'click',
  315. onChange: function onChange(item, collapsed) {
  316. if (!item) return;
  317. item.getModel().collapsed = collapsed;
  318. return true;
  319. },
  320. },
  321. ],
  322. },
  323. layout: {
  324. type: 'mindmap',
  325. direction: 'H',
  326. getHeight: () => 32,
  327. getWidth: () => 140,
  328. getVGap: () => 32,
  329. getHGap: () => 110,
  330. },
  331. defaultNode: {
  332. size: 22,
  333. style: {
  334. stroke: '#94a3b8',
  335. fill: '#fff',
  336. radius: 4,
  337. shadowColor: undefined,
  338. shadowBlur: 0,
  339. lineWidth: 3,
  340. },
  341. labelCfg: {
  342. style: {
  343. fontSize: 13,
  344. fill: '#0f172a',
  345. fontWeight: 500,
  346. },
  347. position: 'right',
  348. offset: 12,
  349. },
  350. },
  351. defaultEdge: {
  352. type: 'cubic-horizontal',
  353. style: {
  354. stroke: '#cbd5f5',
  355. lineWidth: 3,
  356. shadowBlur: 0,
  357. shadowColor: undefined,
  358. },
  359. },
  360. nodeStateStyles: {
  361. selected: {
  362. lineWidth: 3.2,
  363. stroke: '#2563eb',
  364. fill: '#e0f2fe',
  365. },
  366. },
  367. edgeStateStyles: {
  368. highlight: {
  369. lineWidth: 3.4,
  370. stroke: '#fb923c',
  371. },
  372. },
  373. plugins: [],
  374. };
  375. this.graph = new TreeGraphClass(graphConfig);
  376. if (typeof this.graph.data === 'function') {
  377. this.graph.data(graphData);
  378. } else if (typeof this.graph.changeData === 'function') {
  379. this.graph.changeData(graphData);
  380. }
  381. if (typeof this.graph.render === 'function') {
  382. this.graph.render();
  383. }
  384. this.graph.data(this.decorateTree(this.treeData));
  385. this.graph.render();
  386. this.graph.fitView(24);
  387. this.drawRelationEdges();
  388. this.bindEvents();
  389. this.graph.on('node:mouseenter', (evt) => {
  390. const { clientX, clientY } = evt;
  391. showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
  392. });
  393. this.graph.on('node:mouseleave', hideTooltip);
  394. this.graph.on('edge:mouseenter', (evt) => {
  395. const model = evt?.item?.getModel() || {};
  396. const relation = model.label || '关联关系';
  397. const text = `${model.source || ''} → ${model.target || ''}`;
  398. const comment = model.comment ? `<div class="text-[11px] text-gray-600 mt-1 whitespace-pre-line">${model.comment}</div>` : '';
  399. const html = `
  400. <div class="rounded-md border border-gray-200 bg-white px-3 py-2 text-xs text-gray-700 shadow-md">
  401. <div class="font-semibold text-gray-900 mb-1">${relation}</div>
  402. <div>${text}</div>
  403. ${comment}
  404. </div>
  405. `;
  406. const { clientX, clientY } = evt;
  407. showTooltip(html, clientX, clientY);
  408. });
  409. this.graph.on('edge:mouseleave', hideTooltip);
  410. const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
  411. if (canvas && typeof canvas.set === 'function') {
  412. canvas.set('localRefresh', false);
  413. const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
  414. if (ctx) {
  415. ctx.shadowColor = 'transparent';
  416. ctx.shadowBlur = 0;
  417. }
  418. }
  419. },
  420. decorateTree(node) {
  421. if (!node) {
  422. return null;
  423. }
  424. const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
  425. return {
  426. id: node.id,
  427. label: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
  428. meta: node.meta,
  429. collapsed: node.collapsed,
  430. depth: node.depth,
  431. size,
  432. style: nodeStyle,
  433. labelCfg,
  434. children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
  435. };
  436. },
  437. getNodeLevelStyle(depth = 0) {
  438. const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
  439. return {
  440. size: style.size || 22,
  441. nodeStyle: {
  442. fill: style.fill || '#fff',
  443. stroke: style.stroke || '#cbd5f5',
  444. lineWidth: 3,
  445. radius: 6,
  446. shadowColor: undefined,
  447. shadowBlur: 0,
  448. },
  449. labelCfg: {
  450. position: 'right',
  451. offset: 12,
  452. style: {
  453. fontSize: style.fontSize || 13,
  454. fontWeight: style.fontWeight || 600,
  455. fill: style.labelColor || '#0f172a',
  456. },
  457. },
  458. };
  459. },
  460. drawRelationEdges() {
  461. if (!this.graph || !this.relationEdges.length) {
  462. return;
  463. }
  464. this.relationEdges.forEach((edge, index) => {
  465. const style = { ...(edge.style || {}), lineAppendWidth: 14 };
  466. if (style.endArrow && !style.endArrow.path) {
  467. style.endArrow = {
  468. ...style.endArrow,
  469. path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
  470. };
  471. }
  472. this.graph.addItem('edge', {
  473. id: `extra-${index}`,
  474. source: edge.source,
  475. target: edge.target,
  476. type: edge.type || 'quadratic',
  477. curveOffset: edge.curveOffset || 50,
  478. style,
  479. label: edge.label,
  480. comment: edge.comment || '',
  481. labelCfg: {
  482. autoRotate: true,
  483. style: {
  484. fill: '#475569',
  485. fontSize: 11,
  486. background: {
  487. fill: 'rgba(255,255,255,0.85)',
  488. padding: [2, 4],
  489. radius: 4,
  490. },
  491. },
  492. },
  493. });
  494. });
  495. },
  496. buildTooltip(model) {
  497. const meta = model?.meta;
  498. if (!meta) {
  499. return '<div class="text-xs text-gray-600">无数据</div>';
  500. }
  501. const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
  502. const skills = (meta.skills || [])
  503. .slice(0, 6)
  504. .map((skill) => `<li class="leading-snug">${skill.trim()}</li>`)
  505. .join('') || '<li>暂无技能</li>';
  506. return `
  507. <div class="min-w-[230px] max-w-sm rounded-md border border-gray-200 bg-white p-3 text-xs text-gray-700 shadow-lg">
  508. <div class="text-sm font-semibold text-gray-900 mb-1">${meta.code || model.id} · ${meta.name}</div>
  509. <div class="flex gap-3 text-xs">
  510. <span>直接:${range(meta.direct_score)}</span>
  511. <span>关联:${range(meta.related_score)}</span>
  512. </div>
  513. <div class="mt-2">
  514. <div class="font-medium">技能要点</div>
  515. <ul class="list-disc pl-5 space-y-0.5">
  516. ${skills}
  517. </ul>
  518. </div>
  519. </div>
  520. `;
  521. },
  522. bindEvents() {
  523. if (!this.graph) {
  524. return;
  525. }
  526. this.graph.on('node:click', (evt) => {
  527. const nodeId = evt?.item?.getID();
  528. if (nodeId) {
  529. this.highlightEdges(nodeId);
  530. }
  531. });
  532. this.graph.on('canvas:click', () => this.resetHighlight());
  533. },
  534. highlightEdges(nodeId) {
  535. this.graph.getNodes().forEach((node) => {
  536. this.graph.clearItemStates(node);
  537. if (node.getID() === nodeId) {
  538. this.graph.setItemState(node, 'selected', true);
  539. }
  540. });
  541. this.graph.getEdges().forEach((edge) => {
  542. const { source, target } = edge.getModel();
  543. const linked = source === nodeId || target === nodeId;
  544. if (linked) {
  545. this.graph.setItemState(edge, 'highlight', true);
  546. } else {
  547. this.graph.clearItemStates(edge);
  548. }
  549. });
  550. },
  551. resetHighlight() {
  552. if (!this.graph) return;
  553. this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
  554. this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
  555. },
  556. resizeGraph() {
  557. if (!this.graph) return;
  558. const container = document.getElementById('knowledge-mindmap');
  559. if (!container) return;
  560. this.graph.changeSize(container.clientWidth, container.clientHeight);
  561. this.graph.fitView(24);
  562. },
  563. });
  564. });
  565. </script>
  566. @endpush