class KnowledgeMindmapGraph { constructor(options = {}) { this.graph = null; this.rawTree = null; this.treeData = null; this.relationEdges = []; this.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.tooltipEl = null; this.nodeIdSet = new Set(); this.lockRules = options.lockRules || [ { prerequisite: 'P04', target: 'P05', threshold: 0.6 }, { prerequisite: 'P05', target: 'P06', threshold: 0.6 }, ]; this.parentMap = {}; this.clickTimer = null; this.clickDelay = 220; this.focusListener = null; this.showEdges = options.showEdges ?? true; this.showRelationEdges = options.showRelationEdges ?? true; this.setMasteryData(options.masteryData || {}); } 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.buildParentMap(this.treeData); this.relationEdges = this.normalizeEdges(rawEdges); const flatIds = []; this.collectIds(this.treeData, flatIds); console.log('知识点总数', flatIds.length, '列表:', flatIds); this.nodeIdSet = new Set(flatIds); this.logMasteryCoverage(); this.stats = { nodes: this.countNodes(this.treeData), extraEdges: this.showRelationEdges ? this.relationEdges.length : 0, }; } 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 masteryInfo = this.masteryData[id] || null; const masteryLevel = this.getMasteryLevel(id); const accuracy = this.toNumber(masteryInfo?.accuracy_rate); const recommended = masteryLevel < 0.6; const model = { id, label: `${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.toNumber(masteryInfo?.total_attempts), mastery_info: masteryInfo, has_mastery: Boolean(masteryInfo), 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 value = this.normalizeMasteryLevel(remote); 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: '#8C8986FF', lineDash: [8, 6], lineWidth: 1, label: '前置' }, successor: { stroke: '#8C8986FF', lineDash: [8, 6], lineWidth: 1, label: '后继' }, crosslink: { stroke: '#8c8a89', lineDash: [8, 6], lineWidth: 1, label: '跨联' }, sibling: { stroke: '#8C8986FF', lineDash: [8, 6], lineWidth: 1, label: '同级' }, }; (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 baseStyle = styleMap[category] || { stroke: '#cbd5e1', lineWidth: 2.5, }; const label = baseStyle.label || edge.label || category; const arrowStroke = baseStyle.stroke || '#cbd5e1'; const style = { ...baseStyle, endArrow: { path: 'M 0,0 L 8,4 L 0,8 z', fill: arrowStroke, d: 8, }, startArrow: false, }; normalized.push({ id: `rel-${index}`, source: edge.source, target: edge.target, type: renderType, edgeType: category, style: { ...style, }, label, comment: edge.comment || edge.note || '', }); }); return normalized; } collectIds(node, bucket) { if (!node) return; bucket.push(node.id); (node.children || []).forEach((child) => this.collectIds(child, bucket)); } 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', ], }, defaultNode: { type: 'hexagon-card', size: 110, }, defaultEdge: { type: 'cubic-horizontal', style: this.showEdges ? { stroke: '#cbd5e1', lineWidth: 2, } : { stroke: 'transparent', lineWidth: 0, opacity: 0, }, }, 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); // 暴露实例便于调试 window.KnowledgeMindmapGraphInstance = this; window.KnowledgeMindmapG6Graph = this.graph; window.focusMindmapNode = (id) => this.focusNodeById(id); // 边提示 if (this.showEdges) { this.bindEdgeTooltip(); } this.applyNodeStates(); if (this.showEdges) { this.startEdgeFlows(); } this.focusOnLowestMastery(); this.repaintNodes(); if (!this.showEdges) { this.hideAllEdges(); } // 折叠/展开或重新布局后重新挂载关联线 this.graph.on('afterlayout', () => { this.redrawRelationEdges(); }); this.setupFocusListener(); } 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 || !this.showRelationEdges || !this.showEdges ) return; this.relationEdges.forEach((edge) => { // 尝试将隐藏节点映射到可见的父节点,避免关联线丢失 let sourceId = edge.source; let targetId = edge.target; const resolveVisible = (id) => { let cur = id; while (cur) { if (this.graph.findById(cur)) return cur; cur = this.parentMap[cur]; } return null; }; if (!this.graph.findById(sourceId)) { const fallback = resolveVisible(sourceId); if (fallback) sourceId = fallback; } if (!this.graph.findById(targetId)) { const fallback = resolveVisible(targetId); if (fallback) targetId = fallback; } const sourceVisible = this.graph.findById(sourceId); const targetVisible = this.graph.findById(targetId); // 若两端都找不到可见节点,直接跳过 if (!sourceVisible && !targetVisible) { return; } // 若只有一端可见,则将不可见端提升到其最近的可见父节点(若仍不可见则跳过) if (!sourceVisible) { const resolved = resolveVisible(edge.source); if (!resolved || !this.graph.findById(resolved)) return; sourceId = resolved; } if (!targetVisible) { const resolved = resolveVisible(edge.target); if (!resolved || !this.graph.findById(resolved)) return; targetId = resolved; } this.graph.addItem('edge', { ...edge, source: sourceId, target: targetId }); }); } applyNodeStates() { if (!this.graph) return; this.graph.getNodes().forEach((node) => { const model = node.getModel(); const mastery = model.meta?.mastery_level ?? 0; const hasMastery = model.meta?.has_mastery ?? Boolean(this.masteryData[model.id]); this.graph.setItemState(node, 'dimmed', !hasMastery); if (model.locked) { this.graph.setItemState(node, 'locked', true); } if (hasMastery && mastery < 0.4 && this.highlightLowMastery) { this.graph.setItemState(node, 'weak', true); } if (hasMastery && mastery >= 0.8 && !model.locked) { this.playHalo(node); } }); } startEdgeFlows() { if (!this.graph || !this.showEdges) 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:mouseover', (evt) => { const item = evt.item; // if (!item || item.getModel().locked) return; this.graph.setItemState(item, 'hover', true); this.highlightNeighbors(item.getModel().id); this.showTooltip(evt, item.getModel()); }); this.graph.on('node:mouseout', (evt) => { const item = evt.item; if (!item) return; this.graph.setItemState(item, 'hover', false); this.clearNeighborHighlight(); this.hideTooltip(); }); this.graph.on('node:click', (evt) => this.handleNodeClick(evt)); // 双击用于折叠/展开 this.graph.on('node:dblclick', (evt) => { if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; } const model = evt.item?.getModel(); if (!model) return; if (model.children && model.children.length > 0) { const nextState = !model.collapsed; this.graph.updateItem(evt.item, { collapsed: nextState }); this.graph.layout?.(); this.redrawRelationEdges(); } }); this.graph.on('canvas:click', () => { this.graph.getNodes().forEach((node) => { this.graph.clearItemStates(node); }); this.clearNeighborHighlight(); this.hideTooltip(); }); } 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) { if (!this.showEdges) return; 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() { if (!this.showEdges) return; this.graph.getEdges().forEach((edge) => { this.graph.clearItemStates(edge, ['hover', 'crosshover']); }); this.graph.getNodes().forEach((node) => { this.graph.setItemState(node, 'dimmed', false); }); } notifySelection(model) { // 派发全局事件,便于外层(Alpine/Livewire)捕获 try { const event = new CustomEvent('mindmap-node-selected', { detail: model, }); window.dispatchEvent(event); } catch (e) { // ignore dispatch errors } 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); } } ensureTooltipEl() { if (this.tooltipEl) return this.tooltipEl; const div = document.createElement('div'); div.style.position = 'fixed'; div.style.zIndex = '9999'; div.style.pointerEvents = 'none'; div.style.padding = '10px 12px'; div.style.background = 'rgba(15,23,42,0.95)'; div.style.color = '#e2e8f0'; div.style.borderRadius = '10px'; div.style.boxShadow = '0 10px 30px rgba(0,0,0,0.18)'; div.style.fontSize = '12px'; div.style.lineHeight = '1.4'; document.body.appendChild(div); this.tooltipEl = div; return div; } showTooltip(evt, model) { const tip = this.ensureTooltipEl(); const mastery = (model.meta?.mastery_level ?? 0) * 100; const attempts = model.meta?.total_attempts ?? 0; const recommended = model.meta?.recommended ? '是' : '否'; const locked = model.locked ? '是' : '否'; tip.innerHTML = `
${model.id} · ${model.meta?.name || model.label}
掌握度:${mastery.toFixed(1)}%
推荐练习:${recommended}
尝试次数:${attempts}
锁定:${locked}
`; const x = evt?.clientX ?? evt?.canvasX ?? evt?.x ?? evt?.event?.clientX ?? 0; const y = evt?.clientY ?? evt?.canvasY ?? evt?.y ?? evt?.event?.clientY ?? 0; tip.style.left = `${x + 16}px`; tip.style.top = `${y + 12}px`; tip.style.opacity = '1'; } hideTooltip() { if (!this.tooltipEl) return; this.tooltipEl.style.opacity = '0'; } setupLivewireListeners() { ['mastery-updated', 'mindmap-mastery-updated'].forEach((event) => { window.addEventListener(event, (detailEvent) => { const payload = detailEvent.detail?.data ?? detailEvent.detail ?? {}; this.setMasteryData(payload); 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.zoom(2); 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.buildParentMap(this.treeData); const flatIds = []; this.collectIds(this.treeData, flatIds); this.nodeIdSet = new Set(flatIds); this.logMasteryCoverage(); this.applyUnlockRules(this.treeData); this.applyInitialCollapse(this.treeData); this.expandForMastery(); // 强制全量重绘,确保 meta/颜色更新 this.graph.clear(); this.graph.data(this.treeData); this.graph.render(); this.repaintNodes(); this.graph.fitView(12); // 暴露实例便于调试(刷新后仍可用) window.KnowledgeMindmapGraphInstance = this; window.KnowledgeMindmapG6Graph = this.graph; this.clearRelationEdges(); this.drawRelationEdges(); this.applyNodeStates(); if (!this.showEdges) { this.hideAllEdges(); } else { this.startEdgeFlows(); } this.focusOnLowestMastery(); this.graph.paint(); this.setupFocusListener(); } bindEdgeTooltip() { if (!this.graph || !this.showEdges) return; const tooltipEl = document.createElement('div'); tooltipEl.style.position = 'fixed'; tooltipEl.style.pointerEvents = 'none'; tooltipEl.style.zIndex = '9999'; tooltipEl.style.display = 'none'; tooltipEl.style.background = 'rgba(15,23,42,0.95)'; tooltipEl.style.color = '#e2e8f0'; tooltipEl.style.padding = '8px 10px'; tooltipEl.style.borderRadius = '8px'; tooltipEl.style.boxShadow = '0 10px 30px rgba(0,0,0,0.18)'; tooltipEl.style.fontSize = '12px'; tooltipEl.style.lineHeight = '1.4'; document.body.appendChild(tooltipEl); const show = (html, x, y) => { tooltipEl.innerHTML = html; tooltipEl.style.left = `${x + 12}px`; tooltipEl.style.top = `${y + 12}px`; tooltipEl.style.display = 'block'; }; const hide = () => { tooltipEl.style.display = 'none'; }; const buildHtml = (model) => { return `
${model.label || '关联'}
${model.source || ''} → ${model.target || ''}
${model.comment ? `
${model.comment}
` : ''} `; }; this.graph.on('edge:mouseenter', (evt) => { const model = evt?.item?.getModel?.() || {}; const { clientX, clientY } = evt; show(buildHtml(model), clientX, clientY); }); this.graph.on('edge:mouseleave', () => hide()); } redrawRelationEdges() { if (!this.showRelationEdges || !this.showEdges) return; this.clearRelationEdges(); this.drawRelationEdges(); } forceCollapseNodes() { if (!this.graph) return; } handleNodeClick(evt) { if (this.clickTimer) { clearTimeout(this.clickTimer); this.clickTimer = null; } this.clickTimer = setTimeout(() => { 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.showTooltip(evt, model); }, this.clickDelay); } focusNodeById(nodeId) { if (!this.graph || !nodeId) return; // 展开到目标节点 this.expandAncestors(nodeId); this.graph.layout?.(); this.redrawRelationEdges(); const target = this.graph.findById(nodeId); if (!target) return; this.graph.getNodes().forEach((node) => { this.graph.clearItemStates(node); }); this.graph.setItemState(target, 'selected', true); this.flashNode(target); this.graph.focusItem(target, true, { duration: 400, easing: 'easeCubic' }); this.graph.paint(); } expandAncestors(nodeId) { let cur = nodeId; while (cur) { const parentId = this.parentMap[cur]; if (parentId && this.graph.findById(parentId)) { this.graph.updateItem(this.graph.findById(parentId), { collapsed: false }); } cur = parentId; } } setupFocusListener() { if (this.focusListener) { window.removeEventListener('mindmap-focus-node', this.focusListener); } this.focusListener = (evt) => { const targetId = evt.detail?.id || evt.detail || null; if (!targetId) return; this.focusNodeById(targetId); }; window.addEventListener('mindmap-focus-node', this.focusListener); } forceCollapse() { if (!this.graph) { setTimeout(() => this.forceCollapse(), 200); return; } 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++; } catch (error) { console.error('折叠节点失败:', model.id, error); } } }); // 重新渲染和适配视图 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); } setMasteryData(payload = {}) { this.masteryData = this.normalizeMasteryPayload(payload); this.masteryCache = {}; this.logMasteryCoverage(); } normalizeMasteryPayload(payload = {}) { // 支持 {masteries: []} / {data: []} / {Target: {KP: {...}}} / 直接的键值对 const map = {}; const addEntry = (entry, fallbackKey = null) => { if (!entry || typeof entry !== 'object') return; const code = entry.kp_code || entry.code || entry.id || fallbackKey; if (!code) return; const masteryLevel = this.normalizeMasteryLevel( entry.mastery_level ); map[code] = { ...entry, kp_code: code, mastery_level: masteryLevel, }; }; const normalizeCandidate = (candidate) => { if (!candidate) return; if (Array.isArray(candidate)) { candidate.forEach((item) => addEntry(item)); return; } if (typeof candidate === 'object') { Object.entries(candidate).forEach(([key, value]) => addEntry(value, key) ); } }; normalizeCandidate(payload.masteries); normalizeCandidate(payload.data); normalizeCandidate(payload.Target); // 允许直接传入键值对 normalizeCandidate(payload); return map; } normalizeMasteryLevel(value) { const num = this.toNumber(value); if (!Number.isFinite(num)) return 0; return Math.max(0, Math.min(1, num)); } toNumber(value) { const num = Number(value); return Number.isFinite(num) ? num : 0; } repaintNodes() { if (!this.graph || !this.treeData) return; const nodeMap = {}; const walk = (node) => { if (!node) return; nodeMap[node.id] = node; (node.children || []).forEach((child) => walk(child)); }; walk(this.treeData); let updatedCount = 0; let firstUpdated = null; this.graph.getNodes().forEach((node) => { const id = node.getModel().id; const freshModel = nodeMap[id]; if (!freshModel) return; // 确保 meta 中 has_mastery 等字段存在 const masteryInfo = this.masteryData[id] || this.masteryData[freshModel.meta?.code] || null; freshModel.meta = { ...freshModel.meta, mastery_level: this.normalizeMasteryLevel( masteryInfo?.mastery_level ?? freshModel.meta?.mastery_level ), has_mastery: Boolean(masteryInfo), mastery_info: masteryInfo, total_attempts: this.toNumber( masteryInfo?.total_attempts ?? freshModel.meta?.total_attempts ), }; this.graph.updateItem(node, freshModel); this.graph.refreshItem?.(node); this.applyDirectStyles(node, freshModel.meta.mastery_level, freshModel.meta.has_mastery); // 直接打上选中状态,避免样式被缓存 if (freshModel.meta.has_mastery) { this.graph.setItemState(node, 'selected', true); } else { this.graph.clearItemStates(node, ['selected']); } updatedCount += 1; if (!firstUpdated) { firstUpdated = { id, mastery: freshModel.meta?.mastery_level, has_mastery: freshModel.meta?.has_mastery, }; } }); // 强制重绘,避免样式缓存 this.graph.paint(); } applyDirectStyles(node, mastery, hasMastery) { // 直接对关键 shape 赋色,避免 G6 缓存导致颜色不变 const keyShape = node.getKeyShape?.(); const group = node.getContainer?.(); if (!keyShape || !group) return; const palette = (() => { if (!hasMastery) { return { fill: '#f8fafc', stroke: '#cbd5e1', card: '#ffffff' }; } if (mastery >= 0.8) return { fill: '#fffbeb', stroke: '#d3b55f', card: '#ffffff' }; if (mastery >= 0.6) return { fill: '#ecfdf3', stroke: '#34d399', card: '#ffffff' }; if (mastery >= 0.4) return { fill: '#fffbeb', stroke: '#f59e0b', card: '#fff7ed' }; return { fill: '#fef2f2', stroke: '#f87171', card: '#fff1f2' }; })(); keyShape.attr({ fill: palette.fill, stroke: palette.stroke, }); const card = group.find((e) => e.get?.('name') === 'card-shape'); if (card) { card.attr({ fill: palette.card }); } // 额外高亮轮廓,确保视觉可见 this.graph.setItemState(node, 'selected', hasMastery); this.graph.refreshItem?.(node); } logMasteryCoverage() { if (!this.nodeIdSet || !this.nodeIdSet.size) return; const masteries = Object.keys(this.masteryData || {}); if (!masteries.length) return; const missing = masteries.filter((id) => !this.nodeIdSet.has(id)); if (missing.length) { console.warn( '掌握度返回的知识点未在图谱中找到:', missing.slice(0, 20), missing.length > 20 ? `...共${missing.length}条` : '' ); } } buildParentMap(node, parentId = null) { if (!node) return; this.parentMap[node.id] = parentId; (node.children || []).forEach((child) => this.buildParentMap(child, node.id) ); } hideAllEdges() { if (!this.graph) return; this.graph.getEdges().forEach((edge) => { this.graph.hideItem(edge); }); } } // 定义KnowledgeMindmapGraph类,确保G6已加载 function defineGraphClass() { if (typeof window.G6 === 'undefined') { setTimeout(defineGraphClass, 100); return; } window.KnowledgeMindmapGraph = KnowledgeMindmapGraph; } // 启动定义流程 defineGraphClass();