class KnowledgeMindmapGraph { constructor(options = {}) { this.graph = null; this.rawTree = null; this.treeData = null; this.relationEdges = []; this.masteryData = options.masteryData || {}; this.masteryCache = {}; this.stats = { nodes: 0, extraEdges: 0 }; this.containerId = options.containerId || 'knowledge-mindmap'; this.livewireMethod = options.livewireMethod || 'openDrawer'; this.onNodeSelect = options.onNodeSelect || null; this.livewireId = options.livewireId || null; this.highlightLowMastery = options.highlightLowMastery ?? true; this.emitSelection = options.emitSelection ?? true; this.lockRules = options.lockRules || [ { prerequisite: 'P04', target: 'P05', threshold: 0.6 }, { prerequisite: 'P05', target: 'P06', threshold: 0.6 }, ]; } async init() { try { await this.loadData(); this.applyUnlockRules(this.treeData); this.applyInitialCollapse(this.treeData); this.expandForMastery(); this.renderGraph(); this.bindEvents(); this.setupLivewireListeners(); window.addEventListener('resize', () => this.resizeGraph()); } catch (error) { console.error('初始化思维导图失败', error); } } async loadData() { const [treeResp, edgesResp] = await Promise.all([ fetch('/data/tree.json'), fetch('/data/edges.json'), ]); this.rawTree = await treeResp.json(); const edges = await edgesResp.json(); const rawEdges = Array.isArray(edges) ? edges : edges?.edges || []; this.masteryCache = {}; this.treeData = this.transformNode(this.rawTree); this.relationEdges = this.normalizeEdges(rawEdges); this.stats = { nodes: this.countNodes(this.treeData), extraEdges: this.relationEdges.length, }; } transformNode(node, depth = 0) { if (!node) return null; const id = node.code || node.id || node.label || `node-${Math.random().toString(36).slice(2, 8)}`; const label = node.name || node.label || node.code || node.id || id; const masteryLevel = this.getMasteryLevel(id); const accuracy = this.masteryData[id]?.accuracy_rate || 0; const recommended = masteryLevel < 0.6; const model = { id, label, depth, locked: false, collapsed: depth > 0 && (node.children || []).length > 0, // 默认折叠所有有子节点的节点(除根节点) meta: { code: id, name: label, mastery_level: masteryLevel, accuracy_rate: accuracy, total_attempts: this.masteryData[id]?.total_attempts || 0, mastery_info: this.masteryData[id] || null, recommended, }, children: (node.children || []) .map((child) => this.transformNode(child, depth + 1)) .filter(Boolean), }; return model; } getMasteryLevel(id) { if (this.masteryCache[id] !== undefined) { return this.masteryCache[id]; } const remote = this.masteryData[id]?.mastery_level; const randomFallback = Math.random() * 0.55 + 0.25; const value = typeof remote === 'number' && !Number.isNaN(remote) ? remote : randomFallback; this.masteryCache[id] = value; return value; } applyUnlockRules(node) { if (!node) return; const rule = this.lockRules.find((item) => item.target === node.id); const prereqMastery = rule ? this.masteryCache[rule.prerequisite] ?? 0 : 1; const lockedByRule = rule ? prereqMastery < rule.threshold : false; node.locked = lockedByRule; if (node.locked) { node.meta.lock_reason = lockedByRule ? `需先掌握前置知识点:${rule.prerequisite}` : '需先掌握前置知识点'; } node.children.forEach((child) => this.applyUnlockRules(child)); } applyInitialCollapse(node, depth = 0) { if (!node) return; // 默认折叠所有有子节点的节点(除了根节点) if (depth > 0 && node.children.length > 0) { node.collapsed = true; } node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1) ); } expandForMastery() { if (!this.treeData) return; const masteryKeys = new Set(Object.keys(this.masteryData || {})); if (!masteryKeys.size) return; this.expandNodesForMastery(this.treeData, masteryKeys); } expandNodesForMastery(node, masteryKeys) { if (!node) return false; const hasMastery = masteryKeys.has(node.id); let childHas = false; (node.children || []).forEach((child) => { if (this.expandNodesForMastery(child, masteryKeys)) { childHas = true; } }); if (hasMastery || childHas) { node.collapsed = false; } return hasMastery || childHas; } countNodes(node) { if (!node) return 0; return ( 1 + node.children.reduce( (sum, child) => sum + this.countNodes(child), 0 ) ); } normalizeEdges(rawEdges) { const seen = new Set(); const normalized = []; const styleMap = { prerequisite: { stroke: '#60a5fa', lineDash: [10, 8], lineWidth: 3 }, successor: { stroke: '#7dd3fc', lineWidth: 3 }, crosslink: { stroke: '#fb923c', lineDash: [8, 6], lineWidth: 2.5 }, sibling: { stroke: '#94a3b8', lineDash: [6, 6], lineWidth: 2.5 }, }; (rawEdges || []).forEach((edge, index) => { if (!edge?.source || !edge?.target) return; const key = `${edge.source}-${edge.target}-${edge.type}`; if (seen.has(key)) return; seen.add(key); const category = edge.type || 'successor'; const renderType = category === 'successor' ? 'cubic-horizontal' : 'quadratic'; const style = styleMap[category] || { stroke: '#cbd5e1', lineWidth: 2.5, }; normalized.push({ id: `rel-${index}`, source: edge.source, target: edge.target, type: renderType, edgeType: category, style: { ...style, }, label: category, }); }); return normalized; } renderGraph() { const container = document.getElementById(this.containerId); if (!container) return; const bounds = container.getBoundingClientRect(); const width = Math.max(bounds.width, 640); const height = Math.max(bounds.height, 640); // 直接在数据层面设置折叠状态 this.setCollapsedState(this.treeData); this.graph = new G6.TreeGraph({ container: this.containerId, width, height, modes: { default: [ 'drag-canvas', 'zoom-canvas', { type: 'collapse-expand', trigger: 'click', onChange: (item, collapsed) => { if (!item) return; item.getModel().collapsed = collapsed; return true; }, }, ], }, defaultNode: { type: 'hexagon-card', size: 110, }, defaultEdge: { type: 'cubic-horizontal', style: { stroke: '#cbd5e1', lineWidth: 2, }, }, nodeStateStyles: { hover: { shadowColor: '#38bdf8', shadowBlur: 24 }, selected: { shadowColor: '#fb923c', shadowBlur: 28 }, dimmed: { opacity: 0.3 }, weak: { opacity: 0.6 }, locked: { opacity: 0.35, cursor: 'not-allowed' }, }, edgeStateStyles: { hover: { lineWidth: 3, stroke: '#38bdf8' }, connected: { opacity: 0.95, lineWidth: 3 }, dimmed: { opacity: 0.25 }, crosshover: { stroke: '#fb923c', lineWidth: 3 }, glow: { shadowColor: '#facc15', shadowBlur: 12 }, }, layout: { type: 'mindmap', direction: 'H', getHeight: () => 110, getWidth: () => 150, getVGap: () => 18, getHGap: () => 50, preventOverlap: true, }, }); this.graph.data(this.treeData); this.graph.render(); this.graph.fitView(12); this.drawRelationEdges(); this.applyNodeStates(); this.startEdgeFlows(); this.focusOnLowestMastery(); } setCollapsedState(nodeData, depth = 0) { if (!nodeData) return; // 折叠除根节点外的所有有子节点的节点 if (depth > 0 && nodeData.children && nodeData.children.length > 0) { if (nodeData.collapsed === undefined) { nodeData.collapsed = true; } } // 递归处理子节点 if (nodeData.children) { nodeData.children.forEach(child => this.setCollapsedState(child, depth + 1)); } } clearRelationEdges() { if (!this.graph) return; this.graph.getEdges().forEach((edge) => { const id = edge.getModel()?.id || ''; if (id.startsWith('rel-')) { this.graph.removeItem(edge); } }); } drawRelationEdges() { if (!this.graph || !this.relationEdges.length) return; this.relationEdges.forEach((edge) => { this.graph.addItem('edge', edge); }); } applyNodeStates() { if (!this.graph) return; this.graph.getNodes().forEach((node) => { const model = node.getModel(); const mastery = model.meta?.mastery_level ?? 0; if (model.locked) { this.graph.setItemState(node, 'locked', true); } if (mastery < 0.4 && this.highlightLowMastery) { this.graph.setItemState(node, 'weak', true); } if (mastery >= 0.8 && !model.locked) { this.playHalo(node); } }); } startEdgeFlows() { if (!this.graph) return; const nodeMap = new Map( this.graph .getNodes() .map((node) => [node.getModel().id, node.getModel()]) ); this.graph.getEdges().forEach((edge) => { const model = edge.getModel(); const sourceMastery = nodeMap.get(model.source)?.meta?.mastery_level ?? 0; const category = model.edgeType || model.type; if (sourceMastery >= 0.8 && category !== 'crosslink') { this.animateEdgeFlow(edge, '#facc15'); } }); } animateEdgeFlow(edge, color) { if (!edge || edge.__flowing) return; const keyShape = edge.getKeyShape?.(); if (!keyShape || !keyShape.getTotalLength) return; const totalLength = keyShape.getTotalLength(); keyShape.attr('lineDash', [20, 12]); keyShape.attr('stroke', color); edge.__flowing = true; keyShape.animate( (ratio) => ({ lineDashOffset: -ratio * totalLength, opacity: 0.8 + 0.2 * Math.sin(ratio * Math.PI), }), { duration: 1600, repeat: true } ); } playHalo(node) { const group = node.getContainer(); const keyShape = node.getKeyShape(); if (!group || !keyShape) return; const bbox = keyShape.getBBox(); const halo = group.addShape('circle', { attrs: { x: bbox.centerX, y: bbox.centerY, r: Math.max(bbox.width, bbox.height) * 0.65, stroke: '#facc15', lineWidth: 2, opacity: 0.4, }, name: 'halo-shape', }); halo.animate( (ratio) => ({ r: halo.attr('r') + ratio * 16, opacity: 0.4 - ratio * 0.4, }), { duration: 1200, easing: 'easeCubic', repeat: false, removeOnEnd: true, } ); } bindEvents() { if (!this.graph) return; this.graph.on('node:mouseenter', (evt) => { const item = evt.item; if (!item || item.getModel().locked) return; this.graph.setItemState(item, 'hover', true); this.highlightNeighbors(item.getModel().id); }); this.graph.on('node:mouseleave', (evt) => { const item = evt.item; if (!item) return; this.graph.setItemState(item, 'hover', false); this.clearNeighborHighlight(); }); this.graph.on('node:click', (evt) => { const model = evt.item?.getModel(); if (!model) return; if (model.locked) { return; } this.graph.getNodes().forEach((node) => { this.graph.clearItemStates(node); }); this.graph.setItemState(evt.item, 'selected', true); this.flashNode(evt.item); if (this.emitSelection) { this.notifySelection(model); } }); this.graph.on('canvas:click', () => { this.graph.getNodes().forEach((node) => { this.graph.clearItemStates(node); }); this.clearNeighborHighlight(); }); } flashNode(item) { const keyShape = item?.getKeyShape?.(); if (!keyShape) return; keyShape.animate({ opacity: 0.8 }, { duration: 80 }); keyShape.animate({ opacity: 1 }, { duration: 200, delay: 80 }); } highlightNeighbors(nodeId) { const connected = new Set([nodeId]); this.graph.getEdges().forEach((edge) => { const model = edge.getModel(); const related = model.source === nodeId || model.target === nodeId; this.graph.setItemState(edge, 'hover', related); const category = model.edgeType || model.type; if (category === 'crosslink') { this.graph.setItemState(edge, 'crosshover', related); } if (related) { connected.add(model.source); connected.add(model.target); } else { this.graph.clearItemStates(edge, ['hover', 'crosshover']); } }); this.graph.getNodes().forEach((node) => { const id = node.getModel().id; this.graph.setItemState(node, 'dimmed', !connected.has(id)); }); } clearNeighborHighlight() { this.graph.getEdges().forEach((edge) => { this.graph.clearItemStates(edge, ['hover', 'crosshover']); }); this.graph.getNodes().forEach((node) => { this.graph.setItemState(node, 'dimmed', false); }); } notifySelection(model) { if (typeof this.onNodeSelect === 'function') { this.onNodeSelect(model); return; } if (!window.Livewire) return; const targetId = this.livewireId || document .querySelector('[data-knowledge-mindmap-root] [wire\\:id], [wire\\:id]') ?.getAttribute('wire:id'); const component = targetId ? window.Livewire.find(targetId) : null; if (component?.call) { component.call(this.livewireMethod, model.id); } } setupLivewireListeners() { ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => { window.addEventListener(event, (detailEvent) => { this.masteryData = detailEvent.detail?.data || {}; this.refreshGraph(); }); }); } focusOnLowestMastery() { if (!this.graph) return; const entries = Object.entries(this.masteryData || {}).filter( ([, value]) => value && typeof value.mastery_level === 'number' ); if (!entries.length) return; let targetId = null; let minLevel = Infinity; entries.forEach(([id, value]) => { const level = value.mastery_level; if (typeof level === 'number' && level < minLevel) { minLevel = level; targetId = id; } }); if (!targetId) return; this.graph.getNodes().forEach((node) => { this.graph.clearItemStates(node); }); const item = this.graph.findById(targetId); if (item) { this.graph.focusItem(item, true, { easing: 'easeCubic', duration: 500, }); this.graph.setItemState(item, 'selected', true); } } refreshGraph() { if (!this.graph || !this.rawTree) return; this.masteryCache = {}; this.treeData = this.transformNode(this.rawTree); this.applyUnlockRules(this.treeData); this.applyInitialCollapse(this.treeData); this.expandForMastery(); this.graph.changeData(this.treeData); this.graph.render(); this.graph.fitView(12); this.clearRelationEdges(); this.drawRelationEdges(); this.applyNodeStates(); this.startEdgeFlows(); this.focusOnLowestMastery(); } forceCollapseNodes() { if (!this.graph) return; } forceCollapse() { if (!this.graph) { console.log('等待graph初始化...'); setTimeout(() => this.forceCollapse(), 200); return; } console.log('开始强制折叠节点...'); const nodes = this.graph.getNodes(); let collapsedCount = 0; nodes.forEach(node => { const model = node.getModel(); // 折叠除根节点外的所有有子节点的节点 if ( this.masteryData[model.id] || (model.meta && this.masteryData[model.meta.code]) ) { model.collapsed = false; return; } if (model.depth > 0 && model.children && model.children.length > 0) { try { this.graph.collapseItem(node); collapsedCount++; console.log('成功折叠节点:', model.id); } catch (error) { console.error('折叠节点失败:', model.id, error); } } }); console.log(`共折叠了 ${collapsedCount} 个节点`); // 重新渲染和适配视图 this.graph.refresh(); this.graph.fitView(12); } resizeGraph() { if (!this.graph) return; const container = document.getElementById(this.containerId); if (!container) return; this.graph.changeSize(container.clientWidth, container.clientHeight); this.graph.fitView(12); } } // 定义KnowledgeMindmapGraph类,确保G6已加载 function defineGraphClass() { if (typeof window.G6 === 'undefined') { console.log('G6库尚未加载,100ms后重试定义Graph类...'); setTimeout(defineGraphClass, 100); return; } console.log('G6库已加载,定义KnowledgeMindmapGraph类...'); window.KnowledgeMindmapGraph = KnowledgeMindmapGraph; console.log('KnowledgeMindmapGraph类定义完成'); } // 启动定义流程 defineGraphClass();