// 等待G6加载完成后再注册自定义节点 function registerCustomNode() { if (typeof window.G6 === 'undefined') { console.log('G6库尚未加载,100ms后重试...'); setTimeout(registerCustomNode, 100); return; } console.log('G6库已加载,开始注册自定义节点...'); G6.registerNode( 'hexagon-card', { draw(cfg, group) { const size = Array.isArray(cfg.size) ? cfg.size[0] : cfg.size || 110; const mastery = Math.max( 0, Math.min(1, cfg.meta?.mastery_level ?? 0) ); const hasMastery = Boolean(cfg.meta?.has_mastery); const percent = Math.round(mastery * 100); const locked = cfg.locked || false; const recommended = cfg.meta?.recommended; const hexagonPath = this.getHexagonPath(size); const palette = this.getPalette(mastery, locked, hasMastery); const ring = group.addShape('path', { attrs: { path: hexagonPath, stroke: palette.ring, lineWidth: palette.ringWidth, opacity: palette.ringOpacity, }, name: 'ring-shape', }); const hexagon = group.addShape('path', { attrs: { path: hexagonPath, fill: palette.fill, stroke: palette.stroke, lineWidth: palette.lineWidth, shadowColor: palette.shadow, shadowBlur: palette.shadow ? 12 : 0, opacity: locked ? 0.55 : hasMastery ? 1 : 0.6, cursor: locked ? 'not-allowed' : 'pointer', }, name: 'hexagon-shape', draggable: true, }); const cardWidth = size * 0.82; const cardHeight = size * 0.64; group.addShape('rect', { attrs: { x: -cardWidth / 2, y: -cardHeight / 2, width: cardWidth, height: cardHeight, fill: palette.cardFill, radius: 6, opacity: locked ? 0.75 : hasMastery ? 0.95 : 0.6, shadowColor: 'rgba(15, 23, 42, 0.12)', shadowBlur: 8, }, name: 'card-shape', }); group.addShape('text', { attrs: { text: `${cfg.id} · ${cfg.label || cfg.meta?.name || cfg.meta?.code || cfg.id}`, x: 0, y: -10, fontSize: 22, fontWeight: 800, fill: locked ? '#94a3b8' : '#0f172a', textAlign: 'center', textBaseline: 'middle', }, name: 'title-text', }); group.addShape('text', { attrs: { text: cfg.meta?.code || cfg.id, x: 0, y: 10, fontSize: 15, fontWeight: 700, fill: '#334155', textAlign: 'center', textBaseline: 'middle', }, name: 'code-text', }); const barWidth = cardWidth - 16; const barHeight = 8; const barY = cardHeight / 2 - 4; group.addShape('text', { attrs: { text: '掌握度', x: -barWidth / 2, y: barY - 10, fontSize: 12, fontWeight: 700, fill: '#334155', textAlign: 'left', textBaseline: 'middle', }, name: 'mastery-label', }); group.addShape('rect', { attrs: { x: -barWidth / 2, y: barY, width: barWidth, height: barHeight, fill: '#e5e7eb', radius: 3, }, name: 'progress-bg', }); group.addShape('rect', { attrs: { x: -barWidth / 2, y: barY, width: (barWidth * percent) / 100, height: barHeight, fill: palette.progress, radius: 3, }, name: 'progress-fill', }); group.addShape('text', { attrs: { text: `${percent}%`, x: 0, y: barY + barHeight / 2, fontSize: 12, fontWeight: 800, fill: '#0f172a', textAlign: 'center', textBaseline: 'middle', }, name: 'percent-text', }); if (recommended && !locked) { group.addShape('circle', { attrs: { x: cardWidth / 2 - 8, y: -cardHeight / 2 + 10, r: 8, fill: 'rgba(251, 191, 36, 0.15)', stroke: '#f59e0b', }, name: 'recommend-pill', }); group.addShape('text', { attrs: { text: '荐', x: cardWidth / 2 - 8, y: -cardHeight / 2 + 10, fontSize: 12, fontWeight: 700, fill: '#b45309', textAlign: 'center', textBaseline: 'middle', }, name: 'recommend-text', }); } if (locked) { group.addShape('rect', { attrs: { x: -cardWidth / 2, y: -cardHeight / 2, width: cardWidth, height: cardHeight, fill: '#ffffff', opacity: 0.4, }, name: 'lock-mask', }); group.addShape('text', { attrs: { text: '🔒', x: 0, y: 0, fontSize: 16, textAlign: 'center', textBaseline: 'middle', }, name: 'lock-icon', }); } if (percent < 40) { group.addShape('rect', { attrs: { x: -cardWidth / 2, y: -cardHeight / 2, width: cardWidth, height: cardHeight, fill: '#ffffff', opacity: 0.15, }, name: 'weak-mask', }); } return hexagon; }, setState(name, value, item) { const group = item.getContainer(); const hexagon = group.find((e) => e.get('name') === 'hexagon-shape'); if (!hexagon) return; if (name === 'hover') { hexagon.animate( { lineWidth: value ? 4 : 3 }, { duration: 180 } ); group.animate( (ratio) => ({ matrix: [ 1 + (value ? ratio * 0.05 : -ratio * 0.05), 0, 0, 0, 1 + (value ? ratio * 0.05 : -ratio * 0.05), 0, 0, 0, 1, ], }), { duration: 200 } ); } if (name === 'selected') { hexagon.attr('shadowColor', value ? '#fb923c' : undefined); hexagon.attr('shadowBlur', value ? 16 : 0); } if (name === 'weak') { hexagon.attr('opacity', value ? 0.75 : 1); } if (name === 'locked') { hexagon.attr('opacity', value ? 0.5 : 1); hexagon.attr('cursor', value ? 'not-allowed' : 'pointer'); } }, getPalette(mastery, locked, hasMastery) { if (!hasMastery) { return { stroke: '#cbd5e1', lineWidth: 3, ring: '#e2e8f0', ringWidth: 6, ringOpacity: 0.35, shadow: 'rgba(148, 163, 184, 0.15)', progress: '#e5e7eb', fill: '#f8fafc', cardFill: '#ffffff', }; } if (mastery >= 0.8) { return { stroke: '#d3b55f', lineWidth: 4, ring: '#f7e4ad', ringWidth: 8, ringOpacity: 0.55, shadow: 'rgba(212, 181, 95, 0.28)', progress: '#22c55e', fill: '#fffbeb', cardFill: '#ffffff', }; } if (mastery >= 0.6) { return { stroke: '#34d399', lineWidth: 3.5, ring: '#bbf7d0', ringWidth: 7, ringOpacity: 0.45, shadow: 'rgba(52, 211, 153, 0.25)', progress: '#34d399', fill: '#ecfdf3', cardFill: '#ffffff', }; } if (mastery >= 0.4) { return { stroke: '#f59e0b', lineWidth: 3.5, ring: '#fde68a', ringWidth: 7, ringOpacity: 0.45, shadow: 'rgba(245, 158, 11, 0.18)', progress: '#f59e0b', fill: '#fffbeb', cardFill: '#fff7ed', }; } return { stroke: '#f87171', lineWidth: 3, ring: '#fecaca', ringWidth: 8, ringOpacity: 0.55, shadow: 'rgba(248, 113, 113, 0.25)', progress: '#f87171', fill: '#fef2f2', cardFill: '#fff1f2', }; }, getHexagonPath(size) { const r = size / 2; const points = []; for (let i = 0; i < 6; i++) { const angle = (Math.PI / 3) * i - Math.PI / 2; points.push([r * Math.cos(angle), r * Math.sin(angle)]); } return [ ['M', points[0][0], points[0][1]], ...points.slice(1).map((p) => ['L', p[0], p[1]]), ['Z'], ]; }, update(cfg, item) { const group = item.getContainer(); const size = Array.isArray(cfg.size) ? cfg.size[0] : cfg.size || 110; const mastery = Math.max( 0, Math.min(1, cfg.meta?.mastery_level ?? 0) ); const hasMastery = Boolean(cfg.meta?.has_mastery); const percent = Math.round(mastery * 100); const locked = cfg.locked || false; const palette = this.getPalette(mastery, locked, hasMastery); const ring = group.find((e) => e.get('name') === 'ring-shape'); if (ring) { ring.attr({ stroke: palette.ring, lineWidth: palette.ringWidth, opacity: palette.ringOpacity, }); } const hexagon = group.find((e) => e.get('name') === 'hexagon-shape'); if (hexagon) { hexagon.attr({ stroke: palette.stroke, lineWidth: palette.lineWidth, shadowColor: palette.shadow, shadowBlur: palette.shadow ? 12 : 0, opacity: locked ? 0.55 : hasMastery ? 1 : 0.6, cursor: locked ? 'not-allowed' : 'pointer', fill: palette.fill, }); } const title = group.find((e) => e.get('name') === 'title-text'); if (title) { title.attr({ text: `${cfg.id} · ${cfg.label || cfg.meta?.name || cfg.meta?.code || cfg.id}`, fill: locked ? '#94a3b8' : '#0f172a', }); } const codeText = group.find((e) => e.get('name') === 'code-text'); if (codeText) { codeText.attr({ text: cfg.meta?.code || cfg.id, }); } const progressFill = group.find((e) => e.get('name') === 'progress-fill'); const progressBg = group.find((e) => e.get('name') === 'progress-bg'); if (progressFill && progressBg) { const cardWidth = size * 0.82; const barWidth = cardWidth - 16; progressFill.attr({ width: (barWidth * percent) / 100, fill: palette.progress, }); } const card = group.find((e) => e.get('name') === 'card-shape'); if (card) { card.attr({ fill: palette.cardFill, }); } const percentText = group.find((e) => e.get('name') === 'percent-text'); if (percentText) { percentText.attr({ text: `${percent}%`, }); } }, }, 'single-node' ); console.log('自定义节点注册完成'); } // 启动注册流程 registerCustomNode();