knowledge-mindmap-graph.js 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645
  1. class KnowledgeMindmapGraph {
  2. constructor(options = {}) {
  3. this.graph = null;
  4. this.rawTree = null;
  5. this.treeData = null;
  6. this.relationEdges = [];
  7. this.masteryData = options.masteryData || {};
  8. this.masteryCache = {};
  9. this.stats = { nodes: 0, extraEdges: 0 };
  10. this.containerId = options.containerId || 'knowledge-mindmap';
  11. this.livewireMethod = options.livewireMethod || 'openDrawer';
  12. this.onNodeSelect = options.onNodeSelect || null;
  13. this.livewireId = options.livewireId || null;
  14. this.highlightLowMastery = options.highlightLowMastery ?? true;
  15. this.emitSelection = options.emitSelection ?? true;
  16. this.lockRules = options.lockRules || [
  17. { prerequisite: 'P04', target: 'P05', threshold: 0.6 },
  18. { prerequisite: 'P05', target: 'P06', threshold: 0.6 },
  19. ];
  20. }
  21. async init() {
  22. try {
  23. await this.loadData();
  24. this.applyUnlockRules(this.treeData);
  25. this.applyInitialCollapse(this.treeData);
  26. this.expandForMastery();
  27. this.renderGraph();
  28. this.bindEvents();
  29. this.setupLivewireListeners();
  30. window.addEventListener('resize', () => this.resizeGraph());
  31. } catch (error) {
  32. console.error('初始化思维导图失败', error);
  33. }
  34. }
  35. async loadData() {
  36. const [treeResp, edgesResp] = await Promise.all([
  37. fetch('/data/tree.json'),
  38. fetch('/data/edges.json'),
  39. ]);
  40. this.rawTree = await treeResp.json();
  41. const edges = await edgesResp.json();
  42. const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
  43. this.masteryCache = {};
  44. this.treeData = this.transformNode(this.rawTree);
  45. this.relationEdges = this.normalizeEdges(rawEdges);
  46. this.stats = {
  47. nodes: this.countNodes(this.treeData),
  48. extraEdges: this.relationEdges.length,
  49. };
  50. }
  51. transformNode(node, depth = 0) {
  52. if (!node) return null;
  53. const id =
  54. node.code ||
  55. node.id ||
  56. node.label ||
  57. `node-${Math.random().toString(36).slice(2, 8)}`;
  58. const label = node.name || node.label || node.code || node.id || id;
  59. const masteryLevel = this.getMasteryLevel(id);
  60. const accuracy = this.masteryData[id]?.accuracy_rate || 0;
  61. const recommended = masteryLevel < 0.6;
  62. const model = {
  63. id,
  64. label,
  65. depth,
  66. locked: false,
  67. collapsed: depth > 0 && (node.children || []).length > 0, // 默认折叠所有有子节点的节点(除根节点)
  68. meta: {
  69. code: id,
  70. name: label,
  71. mastery_level: masteryLevel,
  72. accuracy_rate: accuracy,
  73. total_attempts: this.masteryData[id]?.total_attempts || 0,
  74. mastery_info: this.masteryData[id] || null,
  75. recommended,
  76. },
  77. children: (node.children || [])
  78. .map((child) => this.transformNode(child, depth + 1))
  79. .filter(Boolean),
  80. };
  81. return model;
  82. }
  83. getMasteryLevel(id) {
  84. if (this.masteryCache[id] !== undefined) {
  85. return this.masteryCache[id];
  86. }
  87. const remote = this.masteryData[id]?.mastery_level;
  88. const randomFallback = Math.random() * 0.55 + 0.25;
  89. const value =
  90. typeof remote === 'number' && !Number.isNaN(remote)
  91. ? remote
  92. : randomFallback;
  93. this.masteryCache[id] = value;
  94. return value;
  95. }
  96. applyUnlockRules(node) {
  97. if (!node) return;
  98. const rule = this.lockRules.find((item) => item.target === node.id);
  99. const prereqMastery = rule
  100. ? this.masteryCache[rule.prerequisite] ?? 0
  101. : 1;
  102. const lockedByRule = rule ? prereqMastery < rule.threshold : false;
  103. node.locked = lockedByRule;
  104. if (node.locked) {
  105. node.meta.lock_reason = lockedByRule
  106. ? `需先掌握前置知识点:${rule.prerequisite}`
  107. : '需先掌握前置知识点';
  108. }
  109. node.children.forEach((child) => this.applyUnlockRules(child));
  110. }
  111. applyInitialCollapse(node, depth = 0) {
  112. if (!node) return;
  113. // 默认折叠所有有子节点的节点(除了根节点)
  114. if (depth > 0 && node.children.length > 0) {
  115. node.collapsed = true;
  116. }
  117. node.children.forEach((child) =>
  118. this.applyInitialCollapse(child, depth + 1)
  119. );
  120. }
  121. expandForMastery() {
  122. if (!this.treeData) return;
  123. const masteryKeys = new Set(Object.keys(this.masteryData || {}));
  124. if (!masteryKeys.size) return;
  125. this.expandNodesForMastery(this.treeData, masteryKeys);
  126. }
  127. expandNodesForMastery(node, masteryKeys) {
  128. if (!node) return false;
  129. const hasMastery = masteryKeys.has(node.id);
  130. let childHas = false;
  131. (node.children || []).forEach((child) => {
  132. if (this.expandNodesForMastery(child, masteryKeys)) {
  133. childHas = true;
  134. }
  135. });
  136. if (hasMastery || childHas) {
  137. node.collapsed = false;
  138. }
  139. return hasMastery || childHas;
  140. }
  141. countNodes(node) {
  142. if (!node) return 0;
  143. return (
  144. 1 +
  145. node.children.reduce(
  146. (sum, child) => sum + this.countNodes(child),
  147. 0
  148. )
  149. );
  150. }
  151. normalizeEdges(rawEdges) {
  152. const seen = new Set();
  153. const normalized = [];
  154. const styleMap = {
  155. prerequisite: { stroke: '#60a5fa', lineDash: [10, 8], lineWidth: 3 },
  156. successor: { stroke: '#7dd3fc', lineWidth: 3 },
  157. crosslink: { stroke: '#fb923c', lineDash: [8, 6], lineWidth: 2.5 },
  158. sibling: { stroke: '#94a3b8', lineDash: [6, 6], lineWidth: 2.5 },
  159. };
  160. (rawEdges || []).forEach((edge, index) => {
  161. if (!edge?.source || !edge?.target) return;
  162. const key = `${edge.source}-${edge.target}-${edge.type}`;
  163. if (seen.has(key)) return;
  164. seen.add(key);
  165. const category = edge.type || 'successor';
  166. const renderType =
  167. category === 'successor' ? 'cubic-horizontal' : 'quadratic';
  168. const style = styleMap[category] || {
  169. stroke: '#cbd5e1',
  170. lineWidth: 2.5,
  171. };
  172. normalized.push({
  173. id: `rel-${index}`,
  174. source: edge.source,
  175. target: edge.target,
  176. type: renderType,
  177. edgeType: category,
  178. style: {
  179. ...style,
  180. },
  181. label: category,
  182. });
  183. });
  184. return normalized;
  185. }
  186. renderGraph() {
  187. const container = document.getElementById(this.containerId);
  188. if (!container) return;
  189. const bounds = container.getBoundingClientRect();
  190. const width = Math.max(bounds.width, 640);
  191. const height = Math.max(bounds.height, 640);
  192. // 直接在数据层面设置折叠状态
  193. this.setCollapsedState(this.treeData);
  194. this.graph = new G6.TreeGraph({
  195. container: this.containerId,
  196. width,
  197. height,
  198. modes: {
  199. default: [
  200. 'drag-canvas',
  201. 'zoom-canvas',
  202. {
  203. type: 'collapse-expand',
  204. trigger: 'click',
  205. onChange: (item, collapsed) => {
  206. if (!item) return;
  207. item.getModel().collapsed = collapsed;
  208. return true;
  209. },
  210. },
  211. ],
  212. },
  213. defaultNode: {
  214. type: 'hexagon-card',
  215. size: 110,
  216. },
  217. defaultEdge: {
  218. type: 'cubic-horizontal',
  219. style: {
  220. stroke: '#cbd5e1',
  221. lineWidth: 2,
  222. },
  223. },
  224. nodeStateStyles: {
  225. hover: { shadowColor: '#38bdf8', shadowBlur: 24 },
  226. selected: { shadowColor: '#fb923c', shadowBlur: 28 },
  227. dimmed: { opacity: 0.3 },
  228. weak: { opacity: 0.6 },
  229. locked: { opacity: 0.35, cursor: 'not-allowed' },
  230. },
  231. edgeStateStyles: {
  232. hover: { lineWidth: 3, stroke: '#38bdf8' },
  233. connected: { opacity: 0.95, lineWidth: 3 },
  234. dimmed: { opacity: 0.25 },
  235. crosshover: { stroke: '#fb923c', lineWidth: 3 },
  236. glow: { shadowColor: '#facc15', shadowBlur: 12 },
  237. },
  238. layout: {
  239. type: 'mindmap',
  240. direction: 'H',
  241. getHeight: () => 110,
  242. getWidth: () => 150,
  243. getVGap: () => 18,
  244. getHGap: () => 50,
  245. preventOverlap: true,
  246. },
  247. });
  248. this.graph.data(this.treeData);
  249. this.graph.render();
  250. this.graph.fitView(12);
  251. this.drawRelationEdges();
  252. this.applyNodeStates();
  253. this.startEdgeFlows();
  254. this.focusOnLowestMastery();
  255. }
  256. setCollapsedState(nodeData, depth = 0) {
  257. if (!nodeData) return;
  258. // 折叠除根节点外的所有有子节点的节点
  259. if (depth > 0 && nodeData.children && nodeData.children.length > 0) {
  260. if (nodeData.collapsed === undefined) {
  261. nodeData.collapsed = true;
  262. }
  263. }
  264. // 递归处理子节点
  265. if (nodeData.children) {
  266. nodeData.children.forEach(child => this.setCollapsedState(child, depth + 1));
  267. }
  268. }
  269. clearRelationEdges() {
  270. if (!this.graph) return;
  271. this.graph.getEdges().forEach((edge) => {
  272. const id = edge.getModel()?.id || '';
  273. if (id.startsWith('rel-')) {
  274. this.graph.removeItem(edge);
  275. }
  276. });
  277. }
  278. drawRelationEdges() {
  279. if (!this.graph || !this.relationEdges.length) return;
  280. this.relationEdges.forEach((edge) => {
  281. this.graph.addItem('edge', edge);
  282. });
  283. }
  284. applyNodeStates() {
  285. if (!this.graph) return;
  286. this.graph.getNodes().forEach((node) => {
  287. const model = node.getModel();
  288. const mastery = model.meta?.mastery_level ?? 0;
  289. if (model.locked) {
  290. this.graph.setItemState(node, 'locked', true);
  291. }
  292. if (mastery < 0.4 && this.highlightLowMastery) {
  293. this.graph.setItemState(node, 'weak', true);
  294. }
  295. if (mastery >= 0.8 && !model.locked) {
  296. this.playHalo(node);
  297. }
  298. });
  299. }
  300. startEdgeFlows() {
  301. if (!this.graph) return;
  302. const nodeMap = new Map(
  303. this.graph
  304. .getNodes()
  305. .map((node) => [node.getModel().id, node.getModel()])
  306. );
  307. this.graph.getEdges().forEach((edge) => {
  308. const model = edge.getModel();
  309. const sourceMastery =
  310. nodeMap.get(model.source)?.meta?.mastery_level ?? 0;
  311. const category = model.edgeType || model.type;
  312. if (sourceMastery >= 0.8 && category !== 'crosslink') {
  313. this.animateEdgeFlow(edge, '#facc15');
  314. }
  315. });
  316. }
  317. animateEdgeFlow(edge, color) {
  318. if (!edge || edge.__flowing) return;
  319. const keyShape = edge.getKeyShape?.();
  320. if (!keyShape || !keyShape.getTotalLength) return;
  321. const totalLength = keyShape.getTotalLength();
  322. keyShape.attr('lineDash', [20, 12]);
  323. keyShape.attr('stroke', color);
  324. edge.__flowing = true;
  325. keyShape.animate(
  326. (ratio) => ({
  327. lineDashOffset: -ratio * totalLength,
  328. opacity: 0.8 + 0.2 * Math.sin(ratio * Math.PI),
  329. }),
  330. { duration: 1600, repeat: true }
  331. );
  332. }
  333. playHalo(node) {
  334. const group = node.getContainer();
  335. const keyShape = node.getKeyShape();
  336. if (!group || !keyShape) return;
  337. const bbox = keyShape.getBBox();
  338. const halo = group.addShape('circle', {
  339. attrs: {
  340. x: bbox.centerX,
  341. y: bbox.centerY,
  342. r: Math.max(bbox.width, bbox.height) * 0.65,
  343. stroke: '#facc15',
  344. lineWidth: 2,
  345. opacity: 0.4,
  346. },
  347. name: 'halo-shape',
  348. });
  349. halo.animate(
  350. (ratio) => ({
  351. r: halo.attr('r') + ratio * 16,
  352. opacity: 0.4 - ratio * 0.4,
  353. }),
  354. {
  355. duration: 1200,
  356. easing: 'easeCubic',
  357. repeat: false,
  358. removeOnEnd: true,
  359. }
  360. );
  361. }
  362. bindEvents() {
  363. if (!this.graph) return;
  364. this.graph.on('node:mouseenter', (evt) => {
  365. const item = evt.item;
  366. if (!item || item.getModel().locked) return;
  367. this.graph.setItemState(item, 'hover', true);
  368. this.highlightNeighbors(item.getModel().id);
  369. });
  370. this.graph.on('node:mouseleave', (evt) => {
  371. const item = evt.item;
  372. if (!item) return;
  373. this.graph.setItemState(item, 'hover', false);
  374. this.clearNeighborHighlight();
  375. });
  376. this.graph.on('node:click', (evt) => {
  377. const model = evt.item?.getModel();
  378. if (!model) return;
  379. if (model.locked) {
  380. return;
  381. }
  382. this.graph.getNodes().forEach((node) => {
  383. this.graph.clearItemStates(node);
  384. });
  385. this.graph.setItemState(evt.item, 'selected', true);
  386. this.flashNode(evt.item);
  387. if (this.emitSelection) {
  388. this.notifySelection(model);
  389. }
  390. });
  391. this.graph.on('canvas:click', () => {
  392. this.graph.getNodes().forEach((node) => {
  393. this.graph.clearItemStates(node);
  394. });
  395. this.clearNeighborHighlight();
  396. });
  397. }
  398. flashNode(item) {
  399. const keyShape = item?.getKeyShape?.();
  400. if (!keyShape) return;
  401. keyShape.animate({ opacity: 0.8 }, { duration: 80 });
  402. keyShape.animate({ opacity: 1 }, { duration: 200, delay: 80 });
  403. }
  404. highlightNeighbors(nodeId) {
  405. const connected = new Set([nodeId]);
  406. this.graph.getEdges().forEach((edge) => {
  407. const model = edge.getModel();
  408. const related =
  409. model.source === nodeId || model.target === nodeId;
  410. this.graph.setItemState(edge, 'hover', related);
  411. const category = model.edgeType || model.type;
  412. if (category === 'crosslink') {
  413. this.graph.setItemState(edge, 'crosshover', related);
  414. }
  415. if (related) {
  416. connected.add(model.source);
  417. connected.add(model.target);
  418. } else {
  419. this.graph.clearItemStates(edge, ['hover', 'crosshover']);
  420. }
  421. });
  422. this.graph.getNodes().forEach((node) => {
  423. const id = node.getModel().id;
  424. this.graph.setItemState(node, 'dimmed', !connected.has(id));
  425. });
  426. }
  427. clearNeighborHighlight() {
  428. this.graph.getEdges().forEach((edge) => {
  429. this.graph.clearItemStates(edge, ['hover', 'crosshover']);
  430. });
  431. this.graph.getNodes().forEach((node) => {
  432. this.graph.setItemState(node, 'dimmed', false);
  433. });
  434. }
  435. notifySelection(model) {
  436. if (typeof this.onNodeSelect === 'function') {
  437. this.onNodeSelect(model);
  438. return;
  439. }
  440. if (!window.Livewire) return;
  441. const targetId =
  442. this.livewireId ||
  443. document
  444. .querySelector('[data-knowledge-mindmap-root] [wire\\:id], [wire\\:id]')
  445. ?.getAttribute('wire:id');
  446. const component = targetId ? window.Livewire.find(targetId) : null;
  447. if (component?.call) {
  448. component.call(this.livewireMethod, model.id);
  449. }
  450. }
  451. setupLivewireListeners() {
  452. ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => {
  453. window.addEventListener(event, (detailEvent) => {
  454. this.masteryData = detailEvent.detail?.data || {};
  455. this.refreshGraph();
  456. });
  457. });
  458. }
  459. focusOnLowestMastery() {
  460. if (!this.graph) return;
  461. const entries = Object.entries(this.masteryData || {}).filter(
  462. ([, value]) =>
  463. value && typeof value.mastery_level === 'number'
  464. );
  465. if (!entries.length) return;
  466. let targetId = null;
  467. let minLevel = Infinity;
  468. entries.forEach(([id, value]) => {
  469. const level = value.mastery_level;
  470. if (typeof level === 'number' && level < minLevel) {
  471. minLevel = level;
  472. targetId = id;
  473. }
  474. });
  475. if (!targetId) return;
  476. this.graph.getNodes().forEach((node) => {
  477. this.graph.clearItemStates(node);
  478. });
  479. const item = this.graph.findById(targetId);
  480. if (item) {
  481. this.graph.focusItem(item, true, {
  482. easing: 'easeCubic',
  483. duration: 500,
  484. });
  485. this.graph.setItemState(item, 'selected', true);
  486. }
  487. }
  488. refreshGraph() {
  489. if (!this.graph || !this.rawTree) return;
  490. this.masteryCache = {};
  491. this.treeData = this.transformNode(this.rawTree);
  492. this.applyUnlockRules(this.treeData);
  493. this.applyInitialCollapse(this.treeData);
  494. this.expandForMastery();
  495. this.graph.changeData(this.treeData);
  496. this.graph.render();
  497. this.graph.fitView(12);
  498. this.clearRelationEdges();
  499. this.drawRelationEdges();
  500. this.applyNodeStates();
  501. this.startEdgeFlows();
  502. this.focusOnLowestMastery();
  503. }
  504. forceCollapseNodes() {
  505. if (!this.graph) return;
  506. }
  507. forceCollapse() {
  508. if (!this.graph) {
  509. console.log('等待graph初始化...');
  510. setTimeout(() => this.forceCollapse(), 200);
  511. return;
  512. }
  513. console.log('开始强制折叠节点...');
  514. const nodes = this.graph.getNodes();
  515. let collapsedCount = 0;
  516. nodes.forEach(node => {
  517. const model = node.getModel();
  518. // 折叠除根节点外的所有有子节点的节点
  519. if (
  520. this.masteryData[model.id] ||
  521. (model.meta && this.masteryData[model.meta.code])
  522. ) {
  523. model.collapsed = false;
  524. return;
  525. }
  526. if (model.depth > 0 && model.children && model.children.length > 0) {
  527. try {
  528. this.graph.collapseItem(node);
  529. collapsedCount++;
  530. console.log('成功折叠节点:', model.id);
  531. } catch (error) {
  532. console.error('折叠节点失败:', model.id, error);
  533. }
  534. }
  535. });
  536. console.log(`共折叠了 ${collapsedCount} 个节点`);
  537. // 重新渲染和适配视图
  538. this.graph.refresh();
  539. this.graph.fitView(12);
  540. }
  541. resizeGraph() {
  542. if (!this.graph) return;
  543. const container = document.getElementById(this.containerId);
  544. if (!container) return;
  545. this.graph.changeSize(container.clientWidth, container.clientHeight);
  546. this.graph.fitView(12);
  547. }
  548. }
  549. // 定义KnowledgeMindmapGraph类,确保G6已加载
  550. function defineGraphClass() {
  551. if (typeof window.G6 === 'undefined') {
  552. console.log('G6库尚未加载,100ms后重试定义Graph类...');
  553. setTimeout(defineGraphClass, 100);
  554. return;
  555. }
  556. console.log('G6库已加载,定义KnowledgeMindmapGraph类...');
  557. window.KnowledgeMindmapGraph = KnowledgeMindmapGraph;
  558. console.log('KnowledgeMindmapGraph类定义完成');
  559. }
  560. // 启动定义流程
  561. defineGraphClass();