|
|
@@ -0,0 +1,648 @@
|
|
|
+<!doctype html>
|
|
|
+<html lang="zh-CN">
|
|
|
+<head>
|
|
|
+ <meta charset="UTF-8">
|
|
|
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
|
+ <title>知识图谱脑图(公开查看)</title>
|
|
|
+ <link rel="preconnect" href="https://fonts.googleapis.com">
|
|
|
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
|
|
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@500;600;700&display=swap" rel="stylesheet">
|
|
|
+ <script src="https://cdn.jsdelivr.net/npm/alpinejs@3.x.x/dist/cdn.min.js" defer></script>
|
|
|
+ <script src="https://gw.alipayobjects.com/os/lib/antv/g6/5.0.18/dist/g6.min.js"></script>
|
|
|
+ <style>
|
|
|
+ :root {
|
|
|
+ color-scheme: light;
|
|
|
+ }
|
|
|
+ body {
|
|
|
+ margin: 0;
|
|
|
+ font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
|
|
|
+ background: radial-gradient(circle at 10% 20%, #e0f2fe 0, transparent 20%), radial-gradient(circle at 90% 10%, #fce7f3 0, transparent 18%), #f8fafc;
|
|
|
+ color: #0f172a;
|
|
|
+ }
|
|
|
+ .page {
|
|
|
+ max-width: 1400px;
|
|
|
+ margin: 0 auto;
|
|
|
+ padding: 32px 20px 40px;
|
|
|
+ }
|
|
|
+ .card {
|
|
|
+ background: white;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 14px;
|
|
|
+ box-shadow: 0 10px 30px rgba(15, 23, 42, 0.06);
|
|
|
+ padding: 18px 20px;
|
|
|
+ }
|
|
|
+ .stat {
|
|
|
+ display: inline-flex;
|
|
|
+ align-items: center;
|
|
|
+ gap: 8px;
|
|
|
+ padding: 8px 12px;
|
|
|
+ border-radius: 10px;
|
|
|
+ background: #f8fafc;
|
|
|
+ border: 1px dashed #e2e8f0;
|
|
|
+ font-size: 12px;
|
|
|
+ color: #475569;
|
|
|
+ }
|
|
|
+ #knowledge-mindmap {
|
|
|
+ width: 100%;
|
|
|
+ min-height: 760px;
|
|
|
+ height: 78vh;
|
|
|
+ border: 1px solid #e2e8f0;
|
|
|
+ border-radius: 14px;
|
|
|
+ background: white;
|
|
|
+ }
|
|
|
+ </style>
|
|
|
+</head>
|
|
|
+<body>
|
|
|
+<div class="page" x-data="knowledgeMindmap()" x-init="initMindmap()">
|
|
|
+ <div class="card" style="margin-bottom: 16px;">
|
|
|
+ <div style="display: flex; flex-wrap: wrap; justify-content: space-between; gap: 16px;">
|
|
|
+ <div style="max-width: 780px;">
|
|
|
+ <div style="font-size: 20px; font-weight: 700; color: #0f172a;">初中数学知识图谱 · 思维导图</div>
|
|
|
+ <p style="margin: 8px 0 6px; font-size: 13px; color: #64748b; line-height: 1.5;">
|
|
|
+ tree.json 提供完整层级(模块 → 知识点),edges.json 描述跨节点关系;基于 AntV G6 MindMap 布局,节点可逐层展开/折叠,线条带方向。
|
|
|
+ </p>
|
|
|
+ <div style="display: flex; gap: 10px; flex-wrap: wrap;">
|
|
|
+ <span class="stat">节点总数:<span x-text="stats.nodes"></span></span>
|
|
|
+ <span class="stat">跨边数量:<span x-text="stats.extraEdges"></span></span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ <div style="display: flex; gap: 12px; flex-wrap: wrap; align-items: center; font-size: 12px; color: #475569;">
|
|
|
+ <span style="display:inline-flex;align-items:center;gap:6px;">
|
|
|
+ <span style="display:block;width:16px;height:6px;border-radius:999px;background:#2563eb;"></span> 前置
|
|
|
+ </span>
|
|
|
+ <span style="display:inline-flex;align-items:center;gap:6px;">
|
|
|
+ <span style="display:block;width:16px;height:6px;border-radius:999px;background:#dc2626;"></span> 后继
|
|
|
+ </span>
|
|
|
+ <span style="display:inline-flex;align-items:center;gap:6px;">
|
|
|
+ <span style="display:block;width:16px;height:6px;border-radius:999px;border:2px dashed #64748b;"></span> 兄弟
|
|
|
+ </span>
|
|
|
+ <span style="display:inline-flex;align-items:center;gap:6px;">
|
|
|
+ <span style="display:block;width:16px;height:6px;border-radius:999px;background:#fcd34d;"></span> 联合
|
|
|
+ </span>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+
|
|
|
+ <div id="knowledge-mindmap" aria-label="知识图谱脑图"></div>
|
|
|
+</div>
|
|
|
+
|
|
|
+<script>
|
|
|
+ window.knowledgeMindmap = () => ({
|
|
|
+ graph: null,
|
|
|
+ treeData: null,
|
|
|
+ relationEdges: [],
|
|
|
+ stats: { nodes: 0, extraEdges: 0 },
|
|
|
+ arrow(w = 12, h = 14, r = 5) {
|
|
|
+ if (window.G6?.Arrow?.triangle) {
|
|
|
+ return G6.Arrow.triangle(w, h, r);
|
|
|
+ }
|
|
|
+ return [
|
|
|
+ ['M', 0, 0],
|
|
|
+ ['L', w, h / 2],
|
|
|
+ ['L', 0, h],
|
|
|
+ ['Z'],
|
|
|
+ ];
|
|
|
+ },
|
|
|
+ levelStyles: [
|
|
|
+ {
|
|
|
+ fill: '#0ea5e9',
|
|
|
+ stroke: '#0369a1',
|
|
|
+ labelColor: '#0f172a',
|
|
|
+ fontSize: 17,
|
|
|
+ fontWeight: 700,
|
|
|
+ size: 34,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fill: '#e0f2fe',
|
|
|
+ stroke: '#38bdf8',
|
|
|
+ labelColor: '#0f172a',
|
|
|
+ fontSize: 16,
|
|
|
+ fontWeight: 700,
|
|
|
+ size: 30,
|
|
|
+ },
|
|
|
+ {
|
|
|
+ fill: '#f1f5f9',
|
|
|
+ stroke: '#cbd5e1',
|
|
|
+ labelColor: '#0f172a',
|
|
|
+ fontSize: 14,
|
|
|
+ fontWeight: 600,
|
|
|
+ size: 26,
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ relationStyles: {
|
|
|
+ prerequisite: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 60,
|
|
|
+ style: {
|
|
|
+ stroke: '#2563eb',
|
|
|
+ lineWidth: 3.4,
|
|
|
+ lineDash: [8, 6],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#2563eb',
|
|
|
+ d: 12,
|
|
|
+ },
|
|
|
+ startArrow: false,
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ label: '前置',
|
|
|
+ },
|
|
|
+ successor: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 60,
|
|
|
+ style: {
|
|
|
+ stroke: '#dc2626',
|
|
|
+ lineWidth: 3.4,
|
|
|
+ lineDash: [8, 6],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#dc2626',
|
|
|
+ d: 12,
|
|
|
+ },
|
|
|
+ startArrow: false,
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ label: '后继',
|
|
|
+ },
|
|
|
+ sibling: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 50,
|
|
|
+ style: {
|
|
|
+ stroke: '#64748b',
|
|
|
+ lineDash: [6, 6],
|
|
|
+ lineWidth: 3,
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#64748b',
|
|
|
+ d: 10,
|
|
|
+ },
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ label: '兄弟',
|
|
|
+ },
|
|
|
+ joint: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 50,
|
|
|
+ style: {
|
|
|
+ stroke: '#fcd34d',
|
|
|
+ lineWidth: 3,
|
|
|
+ lineDash: [10, 8],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#fbbf24',
|
|
|
+ d: 10,
|
|
|
+ },
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ label: '联合',
|
|
|
+ },
|
|
|
+ default: {
|
|
|
+ type: 'quadratic',
|
|
|
+ curveOffset: 50,
|
|
|
+ style: {
|
|
|
+ stroke: '#94a3b8',
|
|
|
+ lineWidth: 3,
|
|
|
+ lineDash: [10, 8],
|
|
|
+ endArrow: {
|
|
|
+ path: null,
|
|
|
+ fill: '#94a3b8',
|
|
|
+ d: 10,
|
|
|
+ },
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ label: '',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ async initMindmap() {
|
|
|
+ try {
|
|
|
+ if (this.$nextTick) {
|
|
|
+ await this.$nextTick();
|
|
|
+ }
|
|
|
+ if (!window.G6) {
|
|
|
+ console.error('G6 未加载');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ // 补充箭头路径
|
|
|
+ Object.keys(this.relationStyles).forEach((key) => {
|
|
|
+ const rel = this.relationStyles[key];
|
|
|
+ if (rel?.style && rel.style.endArrow && !rel.style.endArrow.path) {
|
|
|
+ rel.style.endArrow.path = this.arrow(rel.style.endArrow.d || 10, (rel.style.endArrow.d || 10) + 2, 4);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ await this.loadData();
|
|
|
+ this.applyInitialCollapse(this.treeData);
|
|
|
+ this.renderGraph();
|
|
|
+ window.addEventListener('resize', () => this.resizeGraph());
|
|
|
+ } catch (err) {
|
|
|
+ console.error('初始化思维导图失败', err);
|
|
|
+ const container = document.getElementById('knowledge-mindmap');
|
|
|
+ if (container) {
|
|
|
+ container.innerHTML = '<div style="padding:20px;color:#dc2626;">图数据加载失败,请检查 /data/tree.json 与 /data/edges.json 是否可访问。</div>';
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ async loadData() {
|
|
|
+ const treeUrl = '/data/tree.json';
|
|
|
+ const edgeUrl = '/data/edges.json';
|
|
|
+ const [treeResp, edgesResp] = await Promise.all([fetch(treeUrl), fetch(edgeUrl)]);
|
|
|
+ if (!treeResp.ok || !edgesResp.ok) {
|
|
|
+ throw new Error(`数据加载失败 tree:${treeResp.status} edge:${edgesResp.status}`);
|
|
|
+ }
|
|
|
+ const rawTree = await treeResp.json();
|
|
|
+ const edges = await edgesResp.json();
|
|
|
+ const rawEdges = Array.isArray(edges) ? edges : edges?.edges || [];
|
|
|
+ this.treeData = this.transformNode(rawTree);
|
|
|
+ this.relationEdges = this.normalizeEdges(rawEdges);
|
|
|
+ if (!this.treeData) {
|
|
|
+ throw new Error('tree.json 为空或格式不正确');
|
|
|
+ }
|
|
|
+ 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 || '未命名节点';
|
|
|
+ const meta = {
|
|
|
+ code: node.code || node.id || '',
|
|
|
+ name: label,
|
|
|
+ direct_score: node.direct_score || [],
|
|
|
+ related_score: node.related_score || [],
|
|
|
+ skills: node.skills || [],
|
|
|
+ };
|
|
|
+ return {
|
|
|
+ id,
|
|
|
+ label,
|
|
|
+ meta,
|
|
|
+ depth,
|
|
|
+ children: (node.children || []).map((child) => this.transformNode(child, depth + 1)).filter(Boolean),
|
|
|
+ };
|
|
|
+ },
|
|
|
+ applyInitialCollapse(node, depth = 0) {
|
|
|
+ if (!node) return;
|
|
|
+ if (depth >= 2 && node.children.length > 0) {
|
|
|
+ node.collapsed = true;
|
|
|
+ }
|
|
|
+ node.children.forEach((child) => this.applyInitialCollapse(child, depth + 1));
|
|
|
+ },
|
|
|
+ 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 = [];
|
|
|
+ (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 relationStyle = this.relationStyles[edge.type] || this.relationStyles.default;
|
|
|
+ normalized.push({
|
|
|
+ id: `rel-${index}-${edge.source}-${edge.target}`,
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: relationStyle.type || 'quadratic',
|
|
|
+ curveOffset: relationStyle.curveOffset || 50,
|
|
|
+ style: relationStyle.style,
|
|
|
+ label: relationStyle.label,
|
|
|
+ comment: edge.comment || edge.note || '',
|
|
|
+ });
|
|
|
+ });
|
|
|
+ return normalized;
|
|
|
+ },
|
|
|
+ renderGraph(containerEl = null) {
|
|
|
+ if (!this.treeData) return;
|
|
|
+ const container = containerEl || document.getElementById('knowledge-mindmap');
|
|
|
+ if (!container) {
|
|
|
+ console.error('容器未找到');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const ensuredId = container.id || 'knowledge-mindmap';
|
|
|
+ if (!container.id) {
|
|
|
+ container.id = ensuredId;
|
|
|
+ }
|
|
|
+ const bounds = container.getBoundingClientRect();
|
|
|
+ const width = Math.max(bounds.width || container.clientWidth || 0, 600);
|
|
|
+ const height = Math.max(bounds.height || container.clientHeight || 0, 600);
|
|
|
+ const tooltipEl = document.createElement('div');
|
|
|
+ tooltipEl.style.position = 'fixed';
|
|
|
+ tooltipEl.style.pointerEvents = 'none';
|
|
|
+ tooltipEl.style.zIndex = '9999';
|
|
|
+ tooltipEl.style.display = 'none';
|
|
|
+ document.body.appendChild(tooltipEl);
|
|
|
+ const showTooltip = (html, x, y) => {
|
|
|
+ tooltipEl.innerHTML = html;
|
|
|
+ tooltipEl.style.left = `${x + 12}px`;
|
|
|
+ tooltipEl.style.top = `${y + 12}px`;
|
|
|
+ tooltipEl.style.display = 'block';
|
|
|
+ };
|
|
|
+ const hideTooltip = () => {
|
|
|
+ tooltipEl.style.display = 'none';
|
|
|
+ tooltipEl.innerHTML = '';
|
|
|
+ };
|
|
|
+ const G6Lib = window.G6?.default || window.G6;
|
|
|
+ const TreeGraphClass = G6Lib?.TreeGraph || null;
|
|
|
+ if (!TreeGraphClass) {
|
|
|
+ console.error('G6 TreeGraph 不可用');
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ const graphData = this.decorateTree(this.treeData);
|
|
|
+ const graphConfig = {
|
|
|
+ container: ensuredId,
|
|
|
+ width,
|
|
|
+ height,
|
|
|
+ data: graphData,
|
|
|
+ linkCenter: true,
|
|
|
+ modes: {
|
|
|
+ default: [
|
|
|
+ 'drag-canvas',
|
|
|
+ 'zoom-canvas',
|
|
|
+ {
|
|
|
+ type: 'collapse-expand',
|
|
|
+ trigger: 'click',
|
|
|
+ onChange: function onChange(item, collapsed) {
|
|
|
+ if (!item) return;
|
|
|
+ item.getModel().collapsed = collapsed;
|
|
|
+ return true;
|
|
|
+ },
|
|
|
+ },
|
|
|
+ ],
|
|
|
+ },
|
|
|
+ layout: {
|
|
|
+ type: 'mindmap',
|
|
|
+ direction: 'H',
|
|
|
+ getHeight: () => 32,
|
|
|
+ getWidth: () => 140,
|
|
|
+ getVGap: () => 32,
|
|
|
+ getHGap: () => 110,
|
|
|
+ },
|
|
|
+ defaultNode: {
|
|
|
+ size: 22,
|
|
|
+ style: {
|
|
|
+ stroke: '#94a3b8',
|
|
|
+ fill: '#fff',
|
|
|
+ radius: 4,
|
|
|
+ shadowColor: undefined,
|
|
|
+ shadowBlur: 0,
|
|
|
+ lineWidth: 3,
|
|
|
+ },
|
|
|
+ labelCfg: {
|
|
|
+ style: {
|
|
|
+ fontSize: 13,
|
|
|
+ fill: '#0f172a',
|
|
|
+ fontWeight: 500,
|
|
|
+ },
|
|
|
+ position: 'right',
|
|
|
+ offset: 12,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ defaultEdge: {
|
|
|
+ type: 'cubic-horizontal',
|
|
|
+ style: {
|
|
|
+ stroke: '#cbd5f5',
|
|
|
+ lineWidth: 3,
|
|
|
+ shadowBlur: 0,
|
|
|
+ shadowColor: undefined,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ nodeStateStyles: {
|
|
|
+ selected: {
|
|
|
+ lineWidth: 3.2,
|
|
|
+ stroke: '#2563eb',
|
|
|
+ fill: '#e0f2fe',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ edgeStateStyles: {
|
|
|
+ highlight: {
|
|
|
+ lineWidth: 3.4,
|
|
|
+ stroke: '#fb923c',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ plugins: [],
|
|
|
+ };
|
|
|
+ try {
|
|
|
+ this.graph = new TreeGraphClass(graphConfig);
|
|
|
+ if (typeof this.graph.data === 'function') {
|
|
|
+ this.graph.data(graphData);
|
|
|
+ } else if (typeof this.graph.changeData === 'function') {
|
|
|
+ this.graph.changeData(graphData);
|
|
|
+ }
|
|
|
+ if (typeof this.graph.render === 'function') {
|
|
|
+ this.graph.render();
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ console.error('创建图失败', e);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ this.drawRelationEdges();
|
|
|
+ this.graph.fitView(24);
|
|
|
+ this.bindEvents();
|
|
|
+ // 手动 tooltip
|
|
|
+ const handleNodeEnter = (evt) => {
|
|
|
+ const { clientX, clientY } = evt;
|
|
|
+ showTooltip(this.buildTooltip(evt?.item?.getModel()), clientX, clientY);
|
|
|
+ };
|
|
|
+ const handleEdgeEnter = (evt) => {
|
|
|
+ const model = evt?.item?.getModel() || {};
|
|
|
+ const relation = model.label || '关联关系';
|
|
|
+ const text = `${model.source || ''} → ${model.target || ''}`;
|
|
|
+ const comment = model.comment ? `<div style="font-size:11px;color:#475569;margin-top:4px;white-space:pre-line;">备注:${model.comment}</div>` : '';
|
|
|
+ const html = `
|
|
|
+ <div style="border:1px solid #e2e8f0;border-radius:8px;background:white;padding:8px 10px;font-size:12px;color:#475569;box-shadow:0 10px 20px rgba(15,23,42,0.08);">
|
|
|
+ <div style="font-weight:700;color:#0f172a;margin-bottom:4px;">${relation}</div>
|
|
|
+ <div>${text}</div>
|
|
|
+ ${comment}
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ const { clientX, clientY } = evt;
|
|
|
+ showTooltip(html, clientX, clientY);
|
|
|
+ };
|
|
|
+ const handleLeave = () => hideTooltip();
|
|
|
+ this.graph.on('node:mouseenter', handleNodeEnter);
|
|
|
+ this.graph.on('node:mouseleave', handleLeave);
|
|
|
+ this.graph.on('edge:mouseenter', handleEdgeEnter);
|
|
|
+ this.graph.on('edge:mouseleave', handleLeave);
|
|
|
+ // 全量刷新,避免缩放重影
|
|
|
+ const canvas = this.graph && typeof this.graph.get === 'function' ? this.graph.get('canvas') : null;
|
|
|
+ if (canvas && typeof canvas.set === 'function') {
|
|
|
+ canvas.set('localRefresh', false);
|
|
|
+ const ctx = typeof canvas.get === 'function' ? canvas.get('context') : null;
|
|
|
+ if (ctx) {
|
|
|
+ ctx.shadowColor = 'transparent';
|
|
|
+ ctx.shadowBlur = 0;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ decorateTree(node) {
|
|
|
+ if (!node) return null;
|
|
|
+ const { nodeStyle, labelCfg, size } = this.getNodeLevelStyle(node.depth);
|
|
|
+ return {
|
|
|
+ id: node.id,
|
|
|
+ label: `${node.meta.code ? `${node.meta.code} · ` : ''}${node.label}`,
|
|
|
+ meta: node.meta,
|
|
|
+ collapsed: node.collapsed,
|
|
|
+ depth: node.depth,
|
|
|
+ size,
|
|
|
+ style: nodeStyle,
|
|
|
+ labelCfg,
|
|
|
+ children: node.children.map((child) => this.decorateTree(child)).filter(Boolean),
|
|
|
+ };
|
|
|
+ },
|
|
|
+ getNodeLevelStyle(depth = 0) {
|
|
|
+ const style = this.levelStyles[depth] || this.levelStyles[this.levelStyles.length - 1];
|
|
|
+ return {
|
|
|
+ size: style.size || 22,
|
|
|
+ nodeStyle: {
|
|
|
+ fill: style.fill || '#fff',
|
|
|
+ stroke: style.stroke || '#cbd5f5',
|
|
|
+ lineWidth: 3,
|
|
|
+ radius: 6,
|
|
|
+ shadowColor: undefined,
|
|
|
+ shadowBlur: 0,
|
|
|
+ },
|
|
|
+ labelCfg: {
|
|
|
+ position: 'right',
|
|
|
+ offset: 12,
|
|
|
+ style: {
|
|
|
+ fontSize: style.fontSize || 13,
|
|
|
+ fontWeight: style.fontWeight || 600,
|
|
|
+ fill: style.labelColor || '#0f172a',
|
|
|
+ },
|
|
|
+ },
|
|
|
+ };
|
|
|
+ },
|
|
|
+ drawRelationEdges() {
|
|
|
+ if (!this.graph || !this.relationEdges.length) return;
|
|
|
+ const canAddEdge = typeof this.graph.addEdge === 'function';
|
|
|
+ const canAddItem = typeof this.graph.addItem === 'function';
|
|
|
+ const buildModel = (edge, index) => {
|
|
|
+ const style = { ...(edge.style || {}), lineAppendWidth: 14 };
|
|
|
+ if (style.endArrow && !style.endArrow.path) {
|
|
|
+ style.endArrow = {
|
|
|
+ ...style.endArrow,
|
|
|
+ path: this.arrow(style.endArrow.d || 10, (style.endArrow.d || 10) + 2, 4),
|
|
|
+ };
|
|
|
+ }
|
|
|
+ return {
|
|
|
+ id: `extra-${index}`,
|
|
|
+ source: edge.source,
|
|
|
+ target: edge.target,
|
|
|
+ type: edge.type || 'quadratic',
|
|
|
+ curveOffset: edge.curveOffset || 50,
|
|
|
+ style,
|
|
|
+ label: edge.label,
|
|
|
+ comment: edge.comment || '',
|
|
|
+ labelCfg: {
|
|
|
+ autoRotate: true,
|
|
|
+ style: {
|
|
|
+ fill: '#475569',
|
|
|
+ fontSize: 11,
|
|
|
+ background: {
|
|
|
+ fill: 'rgba(255,255,255,0.85)',
|
|
|
+ padding: [2, 4],
|
|
|
+ radius: 4,
|
|
|
+ },
|
|
|
+ },
|
|
|
+ },
|
|
|
+ };
|
|
|
+ };
|
|
|
+ if (this.isTreeGraph && (canAddEdge || canAddItem)) {
|
|
|
+ this.relationEdges.forEach((edge, index) => {
|
|
|
+ const model = buildModel(edge, index);
|
|
|
+ if (canAddEdge) {
|
|
|
+ this.graph.addEdge(model);
|
|
|
+ } else {
|
|
|
+ this.graph.addItem('edge', model);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ if (typeof this.graph.paint === 'function') {
|
|
|
+ this.graph.paint();
|
|
|
+ }
|
|
|
+ } else {
|
|
|
+ const currentData = this.graph.save?.() || this.graphDataset || { nodes: [], edges: [] };
|
|
|
+ const nodes = currentData.nodes || [];
|
|
|
+ const mergedEdges = (currentData.edges || []).concat(
|
|
|
+ this.relationEdges.map((edge, index) => buildModel(edge, index))
|
|
|
+ );
|
|
|
+ const merged = { nodes, edges: mergedEdges };
|
|
|
+ if (typeof this.graph.changeData === 'function') {
|
|
|
+ this.graph.changeData(merged);
|
|
|
+ } else if (typeof this.graph.data === 'function' && typeof this.graph.render === 'function') {
|
|
|
+ this.graph.data(merged);
|
|
|
+ this.graph.render();
|
|
|
+ }
|
|
|
+ }
|
|
|
+ },
|
|
|
+ buildTooltip(model) {
|
|
|
+ const meta = model?.meta;
|
|
|
+ if (!meta) return '<div class="text-xs text-gray-600">无数据</div>';
|
|
|
+ const range = (value) => (value?.length ? `${value[0]}-${value[1]}` : '未配置');
|
|
|
+ const skills = (meta.skills || [])
|
|
|
+ .slice(0, 6)
|
|
|
+ .map((skill) => `<li style="line-height:1.1;">${skill.trim()}</li>`)
|
|
|
+ .join('') || '<li>暂无技能</li>';
|
|
|
+ return `
|
|
|
+ <div style="min-width:230px;max-width:340px;border:1px solid #e2e8f0;border-radius:8px;background:white;padding:10px;font-size:12px;color:#475569;box-shadow:0 10px 30px rgba(15,23,42,0.12);">
|
|
|
+ <div style="font-size:14px;font-weight:700;color:#0f172a;margin-bottom:4px;">${meta.code || model.id} · ${meta.name}</div>
|
|
|
+ <div style="display:flex;gap:12px;font-size:12px;">
|
|
|
+ <span>直接:${range(meta.direct_score)}</span>
|
|
|
+ <span>关联:${range(meta.related_score)}</span>
|
|
|
+ </div>
|
|
|
+ <div style="margin-top:8px;">
|
|
|
+ <div style="font-weight:600;">技能要点</div>
|
|
|
+ <ul style="padding-left:18px;margin:6px 0; list-style: disc; display: grid; gap: 4px;">
|
|
|
+ ${skills}
|
|
|
+ </ul>
|
|
|
+ </div>
|
|
|
+ </div>
|
|
|
+ `;
|
|
|
+ },
|
|
|
+ bindEvents() {
|
|
|
+ if (!this.graph) return;
|
|
|
+ this.graph.on('node:click', (evt) => {
|
|
|
+ const nodeId = evt?.item?.getID();
|
|
|
+ if (nodeId) this.highlightEdges(nodeId);
|
|
|
+ });
|
|
|
+ this.graph.on('canvas:click', () => this.resetHighlight());
|
|
|
+ },
|
|
|
+ highlightEdges(nodeId) {
|
|
|
+ this.graph.getNodes().forEach((node) => {
|
|
|
+ this.graph.clearItemStates(node);
|
|
|
+ if (node.getID() === nodeId) {
|
|
|
+ this.graph.setItemState(node, 'selected', true);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ this.graph.getEdges().forEach((edge) => {
|
|
|
+ const { source, target } = edge.getModel();
|
|
|
+ const linked = source === nodeId || target === nodeId;
|
|
|
+ if (linked) {
|
|
|
+ this.graph.setItemState(edge, 'highlight', true);
|
|
|
+ } else {
|
|
|
+ this.graph.clearItemStates(edge);
|
|
|
+ }
|
|
|
+ });
|
|
|
+ },
|
|
|
+ resetHighlight() {
|
|
|
+ if (!this.graph) return;
|
|
|
+ this.graph.getNodes().forEach((node) => this.graph.clearItemStates(node));
|
|
|
+ this.graph.getEdges().forEach((edge) => this.graph.clearItemStates(edge));
|
|
|
+ },
|
|
|
+ resizeGraph() {
|
|
|
+ if (!this.graph) return;
|
|
|
+ const container = document.getElementById('knowledge-mindmap');
|
|
|
+ if (!container) return;
|
|
|
+ this.graph.changeSize(container.clientWidth, container.clientHeight);
|
|
|
+ this.graph.fitView(24);
|
|
|
+ },
|
|
|
+ });
|
|
|
+</script>
|
|
|
+</body>
|
|
|
+</html>
|