knowledge-mindmap-graph.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565
  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. if (depth > 0 && node.children.length > 0) {
  114. node.collapsed = true;
  115. }
  116. node.children.forEach((child) =>
  117. this.applyInitialCollapse(child, depth + 1)
  118. );
  119. }
  120. expandForMastery() {
  121. if (!this.treeData) return;
  122. const masteryKeys = new Set(Object.keys(this.masteryData || {}));
  123. if (!masteryKeys.size) return;
  124. this.expandNodesForMastery(this.treeData, masteryKeys);
  125. }
  126. expandNodesForMastery(node, masteryKeys) {
  127. if (!node) return false;
  128. const hasMastery = masteryKeys.has(node.id);
  129. let childHas = false;
  130. (node.children || []).forEach((child) => {
  131. if (this.expandNodesForMastery(child, masteryKeys)) {
  132. childHas = true;
  133. }
  134. });
  135. if (hasMastery || childHas) {
  136. node.collapsed = false;
  137. }
  138. return hasMastery || childHas;
  139. }
  140. countNodes(node) {
  141. if (!node) return 0;
  142. return (
  143. 1 +
  144. node.children.reduce(
  145. (sum, child) => sum + this.countNodes(child),
  146. 0
  147. )
  148. );
  149. }
  150. normalizeEdges(rawEdges) {
  151. const seen = new Set();
  152. const normalized = [];
  153. const styleMap = {
  154. prerequisite: { stroke: '#60a5fa', lineDash: [10, 8], lineWidth: 3 },
  155. successor: { stroke: '#7dd3fc', lineWidth: 3 },
  156. crosslink: { stroke: '#fb923c', lineDash: [8, 6], lineWidth: 2.5 },
  157. sibling: { stroke: '#94a3b8', lineDash: [6, 6], lineWidth: 2.5 },
  158. };
  159. (rawEdges || []).forEach((edge, index) => {
  160. if (!edge?.source || !edge?.target) return;
  161. const key = `${edge.source}-${edge.target}-${edge.type}`;
  162. if (seen.has(key)) return;
  163. seen.add(key);
  164. const category = edge.type || 'successor';
  165. const renderType =
  166. category === 'successor' ? 'cubic-horizontal' : 'quadratic';
  167. const style = styleMap[category] || {
  168. stroke: '#cbd5e1',
  169. lineWidth: 2.5,
  170. };
  171. normalized.push({
  172. id: `rel-${index}`,
  173. source: edge.source,
  174. target: edge.target,
  175. type: renderType,
  176. edgeType: category,
  177. style: {
  178. ...style,
  179. },
  180. label: category,
  181. });
  182. });
  183. return normalized;
  184. }
  185. renderGraph() {
  186. const container = document.getElementById(this.containerId);
  187. if (!container) return;
  188. const bounds = container.getBoundingClientRect();
  189. const width = Math.max(bounds.width, 640);
  190. const height = Math.max(bounds.height, 640);
  191. this.graph = new G6.TreeGraph({
  192. container: this.containerId,
  193. width,
  194. height,
  195. modes: {
  196. default: [
  197. 'drag-canvas',
  198. 'zoom-canvas',
  199. {
  200. type: 'collapse-expand',
  201. trigger: 'click',
  202. onChange: (item, collapsed) => {
  203. if (!item) return;
  204. item.getModel().collapsed = collapsed;
  205. return true;
  206. },
  207. },
  208. ],
  209. },
  210. defaultNode: {
  211. type: 'hexagon-card',
  212. size: 110,
  213. },
  214. defaultEdge: {
  215. type: 'cubic-horizontal',
  216. style: {
  217. stroke: '#cbd5e1',
  218. lineWidth: 2,
  219. },
  220. },
  221. nodeStateStyles: {
  222. hover: { shadowColor: '#38bdf8', shadowBlur: 24 },
  223. selected: { shadowColor: '#fb923c', shadowBlur: 28 },
  224. dimmed: { opacity: 0.3 },
  225. weak: { opacity: 0.6 },
  226. locked: { opacity: 0.35, cursor: 'not-allowed' },
  227. },
  228. edgeStateStyles: {
  229. hover: { lineWidth: 3, stroke: '#38bdf8' },
  230. connected: { opacity: 0.95, lineWidth: 3 },
  231. dimmed: { opacity: 0.25 },
  232. crosshover: { stroke: '#fb923c', lineWidth: 3 },
  233. glow: { shadowColor: '#facc15', shadowBlur: 12 },
  234. },
  235. layout: {
  236. type: 'mindmap',
  237. direction: 'H',
  238. getHeight: () => 110,
  239. getWidth: () => 150,
  240. getVGap: () => 18,
  241. getHGap: () => 50,
  242. },
  243. });
  244. this.graph.data(this.treeData);
  245. this.graph.render();
  246. this.graph.fitView(12);
  247. this.drawRelationEdges();
  248. this.applyNodeStates();
  249. this.startEdgeFlows();
  250. this.focusOnLowestMastery();
  251. }
  252. clearRelationEdges() {
  253. if (!this.graph) return;
  254. this.graph.getEdges().forEach((edge) => {
  255. const id = edge.getModel()?.id || '';
  256. if (id.startsWith('rel-')) {
  257. this.graph.removeItem(edge);
  258. }
  259. });
  260. }
  261. drawRelationEdges() {
  262. if (!this.graph || !this.relationEdges.length) return;
  263. this.relationEdges.forEach((edge) => {
  264. this.graph.addItem('edge', edge);
  265. });
  266. }
  267. applyNodeStates() {
  268. if (!this.graph) return;
  269. this.graph.getNodes().forEach((node) => {
  270. const model = node.getModel();
  271. const mastery = model.meta?.mastery_level ?? 0;
  272. if (model.locked) {
  273. this.graph.setItemState(node, 'locked', true);
  274. }
  275. if (mastery < 0.4 && this.highlightLowMastery) {
  276. this.graph.setItemState(node, 'weak', true);
  277. }
  278. if (mastery >= 0.8 && !model.locked) {
  279. this.playHalo(node);
  280. }
  281. });
  282. }
  283. startEdgeFlows() {
  284. if (!this.graph) return;
  285. const nodeMap = new Map(
  286. this.graph
  287. .getNodes()
  288. .map((node) => [node.getModel().id, node.getModel()])
  289. );
  290. this.graph.getEdges().forEach((edge) => {
  291. const model = edge.getModel();
  292. const sourceMastery =
  293. nodeMap.get(model.source)?.meta?.mastery_level ?? 0;
  294. const category = model.edgeType || model.type;
  295. if (sourceMastery >= 0.8 && category !== 'crosslink') {
  296. this.animateEdgeFlow(edge, '#facc15');
  297. }
  298. });
  299. }
  300. animateEdgeFlow(edge, color) {
  301. if (!edge || edge.__flowing) return;
  302. const keyShape = edge.getKeyShape?.();
  303. if (!keyShape || !keyShape.getTotalLength) return;
  304. const totalLength = keyShape.getTotalLength();
  305. keyShape.attr('lineDash', [20, 12]);
  306. keyShape.attr('stroke', color);
  307. edge.__flowing = true;
  308. keyShape.animate(
  309. (ratio) => ({
  310. lineDashOffset: -ratio * totalLength,
  311. opacity: 0.8 + 0.2 * Math.sin(ratio * Math.PI),
  312. }),
  313. { duration: 1600, repeat: true }
  314. );
  315. }
  316. playHalo(node) {
  317. const group = node.getContainer();
  318. const keyShape = node.getKeyShape();
  319. if (!group || !keyShape) return;
  320. const bbox = keyShape.getBBox();
  321. const halo = group.addShape('circle', {
  322. attrs: {
  323. x: bbox.centerX,
  324. y: bbox.centerY,
  325. r: Math.max(bbox.width, bbox.height) * 0.65,
  326. stroke: '#facc15',
  327. lineWidth: 2,
  328. opacity: 0.4,
  329. },
  330. name: 'halo-shape',
  331. });
  332. halo.animate(
  333. (ratio) => ({
  334. r: halo.attr('r') + ratio * 16,
  335. opacity: 0.4 - ratio * 0.4,
  336. }),
  337. {
  338. duration: 1200,
  339. easing: 'easeCubic',
  340. repeat: false,
  341. removeOnEnd: true,
  342. }
  343. );
  344. }
  345. bindEvents() {
  346. if (!this.graph) return;
  347. this.graph.on('node:mouseenter', (evt) => {
  348. const item = evt.item;
  349. if (!item || item.getModel().locked) return;
  350. this.graph.setItemState(item, 'hover', true);
  351. this.highlightNeighbors(item.getModel().id);
  352. });
  353. this.graph.on('node:mouseleave', (evt) => {
  354. const item = evt.item;
  355. if (!item) return;
  356. this.graph.setItemState(item, 'hover', false);
  357. this.clearNeighborHighlight();
  358. });
  359. this.graph.on('node:click', (evt) => {
  360. const model = evt.item?.getModel();
  361. if (!model) return;
  362. if (model.locked) {
  363. return;
  364. }
  365. this.graph.getNodes().forEach((node) => {
  366. this.graph.clearItemStates(node);
  367. });
  368. this.graph.setItemState(evt.item, 'selected', true);
  369. this.flashNode(evt.item);
  370. if (this.emitSelection) {
  371. this.notifySelection(model);
  372. }
  373. });
  374. this.graph.on('canvas:click', () => {
  375. this.graph.getNodes().forEach((node) => {
  376. this.graph.clearItemStates(node);
  377. });
  378. this.clearNeighborHighlight();
  379. });
  380. }
  381. flashNode(item) {
  382. const keyShape = item?.getKeyShape?.();
  383. if (!keyShape) return;
  384. keyShape.animate({ opacity: 0.8 }, { duration: 80 });
  385. keyShape.animate({ opacity: 1 }, { duration: 200, delay: 80 });
  386. }
  387. highlightNeighbors(nodeId) {
  388. const connected = new Set([nodeId]);
  389. this.graph.getEdges().forEach((edge) => {
  390. const model = edge.getModel();
  391. const related =
  392. model.source === nodeId || model.target === nodeId;
  393. this.graph.setItemState(edge, 'hover', related);
  394. const category = model.edgeType || model.type;
  395. if (category === 'crosslink') {
  396. this.graph.setItemState(edge, 'crosshover', related);
  397. }
  398. if (related) {
  399. connected.add(model.source);
  400. connected.add(model.target);
  401. } else {
  402. this.graph.clearItemStates(edge, ['hover', 'crosshover']);
  403. }
  404. });
  405. this.graph.getNodes().forEach((node) => {
  406. const id = node.getModel().id;
  407. this.graph.setItemState(node, 'dimmed', !connected.has(id));
  408. });
  409. }
  410. clearNeighborHighlight() {
  411. this.graph.getEdges().forEach((edge) => {
  412. this.graph.clearItemStates(edge, ['hover', 'crosshover']);
  413. });
  414. this.graph.getNodes().forEach((node) => {
  415. this.graph.setItemState(node, 'dimmed', false);
  416. });
  417. }
  418. notifySelection(model) {
  419. if (typeof this.onNodeSelect === 'function') {
  420. this.onNodeSelect(model);
  421. return;
  422. }
  423. if (!window.Livewire) return;
  424. const targetId =
  425. this.livewireId ||
  426. document
  427. .querySelector('[data-knowledge-mindmap-root] [wire\\:id], [wire\\:id]')
  428. ?.getAttribute('wire:id');
  429. const component = targetId ? window.Livewire.find(targetId) : null;
  430. if (component?.call) {
  431. component.call(this.livewireMethod, model.id);
  432. }
  433. }
  434. setupLivewireListeners() {
  435. ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => {
  436. window.addEventListener(event, (detailEvent) => {
  437. this.masteryData = detailEvent.detail?.data || {};
  438. this.refreshGraph();
  439. });
  440. });
  441. }
  442. focusOnLowestMastery() {
  443. if (!this.graph) return;
  444. const entries = Object.entries(this.masteryData || {}).filter(
  445. ([, value]) =>
  446. value && typeof value.mastery_level === 'number'
  447. );
  448. if (!entries.length) return;
  449. let targetId = null;
  450. let minLevel = Infinity;
  451. entries.forEach(([id, value]) => {
  452. const level = value.mastery_level;
  453. if (typeof level === 'number' && level < minLevel) {
  454. minLevel = level;
  455. targetId = id;
  456. }
  457. });
  458. if (!targetId) return;
  459. this.graph.getNodes().forEach((node) => {
  460. this.graph.clearItemStates(node);
  461. });
  462. const item = this.graph.findById(targetId);
  463. if (item) {
  464. this.graph.focusItem(item, true, {
  465. easing: 'easeCubic',
  466. duration: 500,
  467. });
  468. this.graph.setItemState(item, 'selected', true);
  469. }
  470. }
  471. refreshGraph() {
  472. if (!this.graph || !this.rawTree) return;
  473. this.masteryCache = {};
  474. this.treeData = this.transformNode(this.rawTree);
  475. this.applyUnlockRules(this.treeData);
  476. this.applyInitialCollapse(this.treeData);
  477. this.expandForMastery();
  478. this.graph.changeData(this.treeData);
  479. this.graph.render();
  480. this.graph.fitView(12);
  481. this.clearRelationEdges();
  482. this.drawRelationEdges();
  483. this.applyNodeStates();
  484. this.startEdgeFlows();
  485. this.focusOnLowestMastery();
  486. }
  487. resizeGraph() {
  488. if (!this.graph) return;
  489. const container = document.getElementById(this.containerId);
  490. if (!container) return;
  491. this.graph.changeSize(container.clientWidth, container.clientHeight);
  492. this.graph.fitView(12);
  493. }
  494. }
  495. window.KnowledgeMindmapGraph = KnowledgeMindmapGraph;