| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669 |
- <!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/4.8.24/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 = () => ({
- isTreeGraph: true,
- 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'],
- ];
- },
- // ... (omitted for brevity, but I need to target the right place)
- levelStyles: [
- {
- fill: '#0ea5e9',
- stroke: '#0369a1',
- labelColor: '#0f172a',
- fontSize: 22, // Increased from 17
- fontWeight: 700,
- size: 40, // Increased from 34
- lineWidth: 4, // Explicitly set thicker line
- },
- {
- fill: '#e0f2fe',
- stroke: '#38bdf8',
- labelColor: '#0f172a',
- fontSize: 20, // Increased from 16
- fontWeight: 700,
- size: 36, // Increased from 30
- lineWidth: 4,
- },
- {
- fill: '#f1f5f9',
- stroke: '#cbd5e1',
- labelColor: '#0f172a',
- fontSize: 18, // Increased from 14
- fontWeight: 600,
- size: 32, // Increased from 26
- lineWidth: 3.5,
- },
- ],
- relationStyles: {
- prerequisite: {
- type: 'quadratic',
- curveOffset: 60,
- style: {
- stroke: '#2563eb',
- lineWidth: 5, // Increased from 3.4
- lineDash: [8, 6],
- endArrow: {
- path: null,
- fill: '#2563eb',
- d: 16, // Increased arrow size
- },
- startArrow: false,
- shadowBlur: 0,
- shadowColor: undefined,
- },
- label: '前置',
- },
- successor: {
- type: 'quadratic',
- curveOffset: 60,
- style: {
- stroke: '#dc2626',
- lineWidth: 5, // Increased from 3.4
- lineDash: [8, 6],
- endArrow: {
- path: null,
- fill: '#dc2626',
- d: 16,
- },
- startArrow: false,
- shadowBlur: 0,
- shadowColor: undefined,
- },
- label: '后继',
- },
- sibling: {
- type: 'quadratic',
- curveOffset: 50,
- style: {
- stroke: '#64748b',
- lineDash: [6, 6],
- lineWidth: 4, // Increased from 3
- endArrow: {
- path: null,
- fill: '#64748b',
- d: 14,
- },
- shadowBlur: 0,
- shadowColor: undefined,
- },
- label: '兄弟',
- },
- joint: {
- type: 'quadratic',
- curveOffset: 50,
- style: {
- stroke: '#fcd34d',
- lineWidth: 4, // Increased from 3
- lineDash: [10, 8],
- endArrow: {
- path: null,
- fill: '#fbbf24',
- d: 14,
- },
- shadowBlur: 0,
- shadowColor: undefined,
- },
- label: '联合',
- },
- default: {
- type: 'quadratic',
- curveOffset: 50,
- style: {
- stroke: '#94a3b8',
- lineWidth: 4, // Increased from 3
- lineDash: [10, 8],
- endArrow: {
- path: null,
- fill: '#94a3b8',
- d: 14,
- },
- 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 || 14, (rel.style.endArrow.d || 14) + 4, 6);
- }
- });
- 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: () => 40, // Increased from 32
- getWidth: () => 140,
- getVGap: () => 32,
- getHGap: () => 60, // Decreased from 110
- },
- defaultNode: {
- size: 26, // Increased from 22
- style: {
- stroke: '#94a3b8',
- fill: '#fff',
- radius: 4,
- shadowColor: undefined,
- shadowBlur: 0,
- lineWidth: 4, // Increased from 3
- },
- labelCfg: {
- style: {
- fontSize: 16, // Increased from 13
- fill: '#0f172a',
- fontWeight: 600, // Increased weight
- },
- position: 'right',
- offset: 14,
- },
- },
- defaultEdge: {
- type: 'cubic-horizontal',
- style: {
- stroke: '#cbd5f5',
- lineWidth: 4, // Increased from 3
- shadowBlur: 0,
- shadowColor: undefined,
- },
- },
- nodeStateStyles: {
- selected: {
- lineWidth: 4.5, // Increased
- stroke: '#2563eb',
- fill: '#e0f2fe',
- },
- },
- edgeStateStyles: {
- highlight: {
- lineWidth: 5, // Increased
- 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);
-
- // 监听布局变化,重新绘制关联线
- this.graph.on('afterlayout', () => {
- this.drawRelationEdges();
- });
- // 全量刷新,避免缩放重影
- 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 || 26, // Increased default
- nodeStyle: {
- fill: style.fill || '#fff',
- stroke: style.stroke || '#cbd5f5',
- lineWidth: style.lineWidth || 4, // Increased default
- radius: 6,
- shadowColor: undefined,
- shadowBlur: 0,
- },
- labelCfg: {
- position: 'right',
- offset: 14, // Increased offset
- style: {
- fontSize: style.fontSize || 16, // Increased default
- 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) => {
- // 检查节点是否存在
- if (!this.graph.findById(edge.source) || !this.graph.findById(edge.target)) {
- return;
- }
- const model = buildModel(edge, index);
- // 防止重复添加
- if (this.graph.findById(model.id)) {
- return;
- }
- 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>
|