feat(v3.1): 知识图谱可视化

P3 实现:图谱可视化(优先级1)

新文件:
- docs/graph_visualization.html — D3.js 力导向图可视化
  * 初始显示 TOP100 高连接节点 + 1跳邻居(约2000节点)
  * 双击节点展开邻居
  * 搜索定位节点
  * 缩放/拖拽/点击详情
  * 关系类型彩色编码

- src/api/routes/graph_viz.py — 图数据 API
  * GET /api/v1/graph/data — D3.js 格式图数据
  * GET /api/v1/graph/stats — 图谱统计概览

- docs/graph_data.json — 预导出图数据(2003节点, 2587边, 330KB)

功能:
- 3362 节点 / 3642 边 / 6 种关系类型
- 高连接节点: muc(54), 发了(51), 4GB(50), AI(40), memory(36)
- 滚动缩放 / 拖拽平移 / 双击展开邻居 / 搜索定位
- info-panel 显示节点详情和所有关联关系
This commit is contained in:
小唯 A06 2026-05-26 18:29:03 +08:00
parent a05d13e209
commit 311dd133bb
3 changed files with 584 additions and 0 deletions

1
docs/graph_data.json Normal file

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,420 @@
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>织忆·知识图谱</title>
<script src="https://d3js.org/d3.v7.min.js"></script>
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: #0d1117;
color: #e6edf3;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
overflow: hidden;
}
#header {
position: fixed; top: 0; left: 0; right: 0;
padding: 16px 24px;
background: rgba(13,17,23,0.9);
border-bottom: 1px solid #30363d;
display: flex; align-items: center; gap: 16px;
z-index: 100;
backdrop-filter: blur(8px);
}
#header h1 { font-size: 18px; font-weight: 600; color: #58a6ff; }
#header .stats { font-size: 13px; color: #8b949e; }
#search {
flex: 1; max-width: 320px;
background: #161b22; border: 1px solid #30363d;
border-radius: 8px; padding: 8px 14px;
color: #e6edf3; font-size: 14px; outline: none;
}
#search:focus { border-color: #58a6ff; }
#legend {
position: fixed; bottom: 24px; left: 24px;
background: rgba(22,27,34,0.92);
border: 1px solid #30363d;
border-radius: 10px; padding: 14px 18px;
font-size: 12px; z-index: 100;
backdrop-filter: blur(8px);
}
#legend .title { color: #8b949e; margin-bottom: 10px; font-size: 11px; text-transform: uppercase; letter-spacing: 1px; }
.legend-item { display: flex; align-items: center; gap: 8px; margin: 5px 0; }
.legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; }
#tooltip {
position: fixed; pointer-events: none;
background: rgba(22,27,34,0.96);
border: 1px solid #30363d;
border-radius: 8px; padding: 10px 14px;
font-size: 13px; z-index: 200;
max-width: 280px;
box-shadow: 0 8px 32px rgba(0,0,0,0.4);
display: none;
}
#tooltip .name { color: #58a6ff; font-weight: 600; font-size: 14px; margin-bottom: 4px; }
#tooltip .info { color: #8b949e; line-height: 1.5; }
#tooltip .rel { color: #f0883e; margin-top: 4px; font-size: 12px; }
#info-panel {
position: fixed; top: 70px; right: 20px;
width: 260px;
background: rgba(22,27,34,0.92);
border: 1px solid #30363d;
border-radius: 10px; padding: 16px;
font-size: 13px; z-index: 100;
display: none;
backdrop-filter: blur(8px);
}
#info-panel h3 { color: #58a6ff; margin-bottom: 10px; font-size: 15px; }
#info-panel .field { color: #8b949e; margin: 4px 0; }
#info-panel .field span { color: #e6edf3; }
#controls {
position: fixed; bottom: 24px; right: 24px;
display: flex; flex-direction: column; gap: 8px; z-index: 100;
}
.btn {
background: rgba(22,27,34,0.9);
border: 1px solid #30363d;
color: #8b949e; border-radius: 8px;
padding: 8px 14px; cursor: pointer;
font-size: 13px; text-align: center;
transition: all 0.15s;
backdrop-filter: blur(8px);
}
.btn:hover { border-color: #58a6ff; color: #58a6ff; }
canvas { display: block; }
</style>
</head>
<body>
<div id="header">
<h1>🧠 织忆·知识图谱</h1>
<div class="stats" id="stats">加载中...</div>
<input id="search" placeholder="🔍 搜索节点(回车定位)" />
<div style="margin-left:auto;font-size:12px;color:#484f58;">双击节点展开邻居 · 滚轮缩放 · 拖拽画布</div>
</div>
<div id="tooltip">
<div class="name" id="tt-name"></div>
<div class="info" id="tt-info"></div>
<div class="rel" id="tt-rel"></div>
</div>
<div id="info-panel">
<h3 id="ip-name"></h3>
<div class="field">类型: <span id="ip-type"></span></div>
<div class="field">连接数: <span id="ip-conn"></span></div>
<div class="field" id="ip-rels-title" style="margin-top:10px;color:#8b949e;">关联关系:</div>
<div id="ip-rels" style="color:#8b949e;line-height:1.8;"></div>
</div>
<div id="legend">
<div class="title">关系类型</div>
<div class="legend-item"><div class="legend-dot" style="background:#58a6ff"></div> 使用</div>
<div class="legend-item"><div class="legend-dot" style="background:#f0883e"></div> 关联</div>
<div class="legend-item"><div class="legend-dot" style="background:#3fb950"></div> 相关</div>
<div class="legend-item"><div class="legend-dot" style="background:#d2a8ff"></div> 包含</div>
<div class="legend-item"><div class="legend-dot" style="background:#ff7b72"></div> 认知</div>
<div class="legend-item"><div class="legend-dot" style="background:#ffa657"></div> 服务对象</div>
</div>
<div id="controls">
<div class="btn" id="btn-reset">🔄 重置视图</div>
<div class="btn" id="btn-expand">📍 展开邻居</div>
<div class="btn" id="btn-compact">🔬 精简视图</div>
</div>
<script>
const REL_COLORS = {
'使用': '#58a6ff',
'关联': '#f0883e',
'相关': '#3fb950',
'包含': '#d2a8ff',
'认知': '#ff7b72',
'服务对象': '#ffa657'
};
let fullData = null;
let nodes = [], links = [];
let sim, canvas, ctx;
let transform = d3.zoomIdentity;
let hoveredNode = null;
let selectedNode = null;
let visibleNodes = new Set();
let visibleLinks = [];
// ── 加载数据 ─────────────────────────────────────────────────
d3.json('graph_data.json').then(data => {
fullData = data;
document.getElementById('stats').textContent =
`全量: ${data.nodes.length} 节点 · ${data.links.length} 边`;
// 初始显示高连接节点TOP 150 + 邻居)
initGraph(data);
});
function initGraph(data) {
// 采样取TOP节点 + 1跳邻居
const topN = data.nodes.slice().sort((a,b) => b.connections - a.connections).slice(0, 100);
const topIds = new Set(topN.map(n => n.id));
// 加入邻居
data.links.forEach(l => {
if (topIds.has(l.source)) topIds.add(l.target);
if (topIds.has(l.target)) topIds.add(l.source);
});
// 最终采样节点
const sampledNodes = data.nodes.filter(n => topIds.has(n.id));
const sampledLinks = data.links.filter(l => topIds.has(l.source) && topIds.has(l.target));
nodes = sampledNodes.map(n => ({...n}));
links = sampledLinks.map(l => ({...l}));
visibleNodes = new Set(nodes.map(n => n.id));
const W = window.innerWidth, H = window.innerHeight;
canvas = d3.select('body').append('canvas').attr('width', W).attr('height', H);
ctx = canvas.node().getContext('2d');
sim = d3.forceSimulation(nodes)
.force('link', d3.forceLink(links).id(d => d.id).distance(60).strength(0.4))
.force('charge', d3.forceManyBody().strength(-120))
.force('center', d3.forceCenter(W/2, H/2))
.force('collision', d3.forceCollide(8))
.on('tick', draw);
setupInteraction();
}
// ── 绘图 ──────────────────────────────────────────────────────
function nodeRadius(d) {
return Math.max(4, Math.min(16, 4 + d.connections * 0.4));
}
function draw() {
const W = canvas.node().width, H = canvas.node().height;
ctx.save();
ctx.clearRect(0, 0, W, H);
ctx.translate(transform.x, transform.y);
ctx.scale(transform.k, transform.k);
// 边
links.forEach(l => {
const color = REL_COLORS[l.relation] || '#444';
ctx.beginPath();
ctx.moveTo(l.source.x, l.source.y);
ctx.lineTo(l.target.x, l.target.y);
ctx.globalAlpha = l.source === selectedNode || l.target === selectedNode ? 0.9 : 0.25;
ctx.strokeStyle = color;
ctx.lineWidth = 1;
ctx.stroke();
});
ctx.globalAlpha = 1;
// 节点
nodes.forEach(n => {
const r = nodeRadius(n);
const isSel = n === selectedNode;
const isHov = n === hoveredNode;
// 光晕
if (isSel || isHov) {
ctx.beginPath();
ctx.arc(n.x, n.y, r + 6, 0, Math.PI * 2);
ctx.fillStyle = 'rgba(88,166,255,0.15)';
ctx.fill();
}
// 圆
ctx.beginPath();
ctx.arc(n.x, n.y, r, 0, Math.PI * 2);
ctx.fillStyle = isSel ? '#58a6ff' : isHov ? '#79c0ff' : '#30363d';
ctx.strokeStyle = '#58a6ff';
ctx.lineWidth = isSel || isHov ? 2 : 0.5;
ctx.fill();
ctx.stroke();
// 标签(大多连接节点或悬停)
if (n.connections >= 5 || isHov || isSel) {
ctx.font = `${isHov || isSel ? 13 : 11}px sans-serif`;
ctx.fillStyle = isHov || isSel ? '#e6edf3' : '#8b949e';
ctx.textAlign = 'center';
ctx.fillText(n.name, n.x, n.y - r - 5);
}
});
ctx.restore();
}
// ── 交互 ──────────────────────────────────────────────────────
function setupInteraction() {
const drag = d3.drag()
.subject(e => nodes.find(n => Math.hypot(n.x - e.x, n.y - e.y) < 30) || {})
.on('start', e => { if (!e.subject.x) return; sim.alphaTarget(0.3).restart(); e.subject.fx = e.subject.x; e.subject.fy = e.subject.y; })
.on('drag', e => { e.subject.fx = e.x; e.subject.fy = e.y; })
.on('end', e => { sim.alphaTarget(0); e.subject.fx = null; e.subject.fy = null; });
canvas.call(drag);
canvas.on('mousemove', e => {
const [mx, my] = transform.invert([e.offsetX, e.offsetY]);
const found = nodes.find(n => Math.hypot(n.x - mx, n.y - my) < nodeRadius(n) + 4);
if (found !== hoveredNode) {
hoveredNode = found;
canvas.style.cursor = found ? 'pointer' : 'grab';
draw();
if (found) showTooltip(found, e.offsetX, e.offsetY);
else hideTooltip();
} else if (found) {
moveTooltip(e.offsetX, e.offsetY);
}
});
canvas.on('click', e => {
const [mx, my] = transform.invert([e.offsetX, e.offsetY]);
const found = nodes.find(n => Math.hypot(n.x - mx, n.y - my) < nodeRadius(n) + 4);
selectedNode = found;
draw();
if (found) showInfoPanel(found);
else document.getElementById('info-panel').style.display = 'none';
});
canvas.on('dblclick', e => {
if (!selectedNode) return;
const [mx, my] = transform.invert([e.offsetX, e.offsetY]);
const clicked = nodes.find(n => Math.hypot(n.x - mx, n.y - my) < nodeRadius(n) + 4);
if (clicked) expandNeighbors(clicked);
});
d3.select(window).on('resize', () => {
canvas.attr('width', window.innerWidth).attr('height', window.innerHeight);
sim.force('center', d3.forceCenter(window.innerWidth/2, window.innerHeight/2));
sim.alpha(0.3).restart();
});
// 缩放
d3.zoom().scaleExtent([0.05, 8]).on('zoom', e => {
transform = e.transform;
draw();
})(canvas);
}
function expandNeighbors(node) {
const added = new Set();
const addedLinks = [];
fullData.links.forEach(l => {
const src = typeof l.source === 'object' ? l.source.id : l.source;
const tgt = typeof l.target === 'object' ? l.target.id : l.target;
if (src === node.id && !visibleNodes.has(tgt)) { added.add(tgt); addedLinks.push(l); }
if (tgt === node.id && !visibleNodes.has(src)) { added.add(src); addedLinks.push(l); }
});
added.forEach(id => {
const nd = fullData.nodes.find(n => n.id === id);
if (nd) { nodes.push({...nd}); visibleNodes.add(id); }
});
addedLinks.forEach(l => links.push({...l}));
sim.nodes(nodes);
sim.force('link').links(links);
sim.alpha(0.8).restart();
draw();
document.getElementById('stats').textContent =
`当前: ${nodes.length} 节点 · ${links.length} 边(全量: ${fullData.nodes.length} · ${fullData.links.length}`;
}
function compactView() {
const keep = nodes.filter(n => n.connections >= 3 || n === selectedNode);
const keepIds = new Set(keep.map(n => n.id));
nodes = keep;
links = links.filter(l => keepIds.has((typeof l.source==='object'?l.source.id:l.source)) && keepIds.has((typeof l.target==='object'?l.target.id:l.target)));
visibleNodes = new Set(nodes.map(n => n.id));
sim.nodes(nodes);
sim.force('link').links(links);
sim.alpha(0.5).restart();
draw();
document.getElementById('stats').textContent = `精简: ${nodes.length} 节点 · ${links.length} 边`;
}
// ── 搜索 ──────────────────────────────────────────────────────
document.getElementById('search').addEventListener('keydown', e => {
if (e.key !== 'Enter') return;
const q = e.target.value.trim();
if (!q || !fullData) return;
const found = fullData.nodes.find(n => n.name.includes(q));
if (found) {
// 如果不在可见集中,展开它
if (!visibleNodes.has(found.id)) {
// 找已显示的节点加它进来
nodes.push({...found});
visibleNodes.add(found.id);
const nlinks = fullData.links.filter(l => l.source === found.id || l.target === found.id)
.filter(l => visibleNodes.has(typeof l.target==='object'?l.target.id:l.target) || visibleNodes.has(typeof l.source==='object'?l.source.id:l.source))
.map(l => ({...l}));
links.push(...nlinks);
sim.nodes(nodes);
sim.force('link').links(links);
}
// 聚焦
const n = nodes.find(n => n.id === found.id);
if (n) {
selectedNode = n;
const tx = window.innerWidth/2 - n.x * transform.k;
const ty = window.innerHeight/2 - n.y * transform.k;
transform = transform.translate(tx, ty);
draw();
showInfoPanel(n);
}
}
});
// ── 按钮 ──────────────────────────────────────────────────────
document.getElementById('btn-reset').onclick = () => {
transform = d3.zoomIdentity;
draw();
document.getElementById('info-panel').style.display = 'none';
selectedNode = null;
};
document.getElementById('btn-expand').onclick = () => {
if (selectedNode) expandNeighbors(selectedNode);
else { selectedNode = nodes[Math.floor(Math.random()*nodes.length)]; expandNeighbors(selectedNode); }
};
document.getElementById('btn-compact').onclick = compactView;
// ── 提示 ──────────────────────────────────────────────────────
function showTooltip(n, x, y) {
document.getElementById('tt-name').textContent = n.name;
document.getElementById('tt-info').textContent = `连接数: ${n.connections} · 类型: ${n.group}`;
document.getElementById('tt-rel').textContent = '双击展开邻居';
const tt = document.getElementById('tooltip');
tt.style.display = 'block';
moveTooltip(x, y);
}
function moveTooltip(x, y) {
const tt = document.getElementById('tooltip');
tt.style.left = (x + 14) + 'px';
tt.style.top = (y - 20) + 'px';
}
function hideTooltip() { document.getElementById('tooltip').style.display = 'none'; }
function showInfoPanel(n) {
const panel = document.getElementById('info-panel');
panel.style.display = 'block';
document.getElementById('ip-name').textContent = n.name;
document.getElementById('ip-type').textContent = n.group;
document.getElementById('ip-conn').textContent = n.connections;
const rels = {};
links.forEach(l => {
const src = typeof l.source === 'object' ? l.source.id : l.source;
const tgt = typeof l.target === 'object' ? l.target.id : l.target;
if (src === n.id) rels[tgt] = l.relation;
if (tgt === n.id) rels[src] = l.relation;
});
const relsDiv = document.getElementById('ip-rels');
relsDiv.innerHTML = Object.entries(rels).slice(0, 12).map(([name, rel]) =>
`<div><span style="color:${REL_COLORS[rel]||'#fff'}">${rel}</span> → ${name}</div>`
).join('');
}
</script>
</body>
</html>

163
src/api/routes/graph_viz.py Normal file
View File

@ -0,0 +1,163 @@
"""知识图谱可视化 API — P3 实现: 图谱可视化"""
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
import json
import sqlite3
from pathlib import Path
router = APIRouter()
GRAPH_DB = Path.home() / '.memory-fabric' / 'zhiyi' / 'graph.db'
class GraphNode(BaseModel):
id: str
name: str
group: str
connections: int = 0
properties: dict = {}
class GraphLink(BaseModel):
source: str
target: str
relation: str
properties: dict = {}
class GraphData(BaseModel):
nodes: list[GraphNode]
links: list[GraphLink]
total_nodes: int
total_edges: int
@router.get('/graph/data')
def get_graph_data(limit: int = 2000):
"""获取图谱数据(用于可视化)
- 初始返回高连接节点 + 邻居最多 limit
- 前端负责交互展开更多节点
- 数据格式D3.js force-directed graph 兼容
"""
if not GRAPH_DB.exists():
raise HTTPException(status_code=404, detail="图数据库不存在")
conn = sqlite3.connect(str(GRAPH_DB))
cur = conn.cursor()
# 总数
cur.execute("SELECT COUNT(*) FROM graph_nodes")
total_nodes = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM graph_edges")
total_edges = cur.fetchone()[0]
# 读取全部边建立 entity→id 映射
cur.execute("SELECT id, entity FROM graph_nodes")
entity_to_id = {row[1]: row[0] for row in cur.fetchall()}
id_to_entity = {v: k for k, v in entity_to_id.items()}
# 读取边source/target 是 entity 名字)
cur.execute("SELECT source, target, relation FROM graph_edges")
links_raw = cur.fetchall()
# 统计节点连接数
from collections import Counter
cnt = Counter()
for src, tgt, rel in links_raw:
if src in entity_to_id and tgt in entity_to_id:
cnt[src] += 1
cnt[tgt] += 1
# 取 top-N 高连接节点
top_entities = [e for e, _ in cnt.most_common(limit)]
top_ids = set(top_entities)
# 加入邻居
neighbor_ids = set()
for src, tgt, rel in links_raw:
if src in top_ids and tgt not in top_ids and tgt in entity_to_id:
neighbor_ids.add(tgt)
if tgt in top_ids and src not in top_ids and src in entity_to_id:
neighbor_ids.add(src)
visible_ids = top_ids | neighbor_ids
# 节点列表
visible_entities = list(visible_ids)
nodes = []
for ent in visible_entities:
node_id = entity_to_id.get(ent, ent)
nodes.append({
"id": ent, # 用 entity 名字作为可视化 ID
"name": ent,
"group": "concept",
"connections": cnt.get(ent, 0),
})
# 边列表
links = []
for src, tgt, rel in links_raw:
if src in visible_ids and tgt in visible_ids:
links.append({
"source": src,
"target": tgt,
"relation": rel,
})
conn.close()
return {
"nodes": nodes,
"links": links,
"total_nodes": total_nodes,
"total_edges": total_edges,
}
@router.get('/graph/stats')
def get_graph_stats():
"""图谱统计概览"""
if not GRAPH_DB.exists():
raise HTTPException(status_code=404, detail="图数据库不存在")
conn = sqlite3.connect(str(GRAPH_DB))
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM graph_nodes")
node_count = cur.fetchone()[0]
cur.execute("SELECT COUNT(*) FROM graph_edges")
edge_count = cur.fetchone()[0]
cur.execute("SELECT relation, COUNT(*) FROM graph_edges GROUP BY relation ORDER BY COUNT(*) DESC")
rel_dist = [{"relation": r, "count": c} for r, c in cur.fetchall()]
# 连接数分布
cur.execute("""
SELECT t.c as conn_count, COUNT(*) as num
FROM (
SELECT source as entity FROM graph_edges
UNION ALL
SELECT target as entity FROM graph_edges
) e
JOIN (
SELECT entity, COUNT(*) as c FROM graph_edges GROUP BY entity
) t ON t.entity = e.entity
GROUP BY t.c
ORDER BY t.c DESC
LIMIT 20
""")
conn_dist = [{"connections": c, "count": n} for c, n in cur.fetchall()]
conn.close()
return {
"node_count": node_count,
"edge_count": edge_count,
"relation_distribution": rel_dist,
"connection_distribution": conn_dist,
}