feat: knowledge graph visualization
- Add GetGraph method to GraphStore interface - Implement SQLiteGraphStore.GetGraph for full graph export - Add GET /api/v1/graph/export endpoint with namespace filter - Add static file serving (/static/ path) - Create D3.js force-directed graph visualization: - Namespace switcher (all/hermes-main/openclaw-main/shared) - Node colors by type, size by weight - Edge labels on hover, node detail popup on click - Dark theme, zoom/pan, force simulation
This commit is contained in:
parent
455833a041
commit
fab49fdd9b
|
|
@ -213,6 +213,25 @@ func NewServer() http.Handler {
|
|||
w.Write(data)
|
||||
})
|
||||
|
||||
// 图谱可视化导出
|
||||
mux.HandleFunc("/api/v1/graph/export", func(w http.ResponseWriter, r *http.Request) {
|
||||
ns := r.URL.Query().Get("namespace")
|
||||
nodes, edges := graphStore.GetGraph(ns)
|
||||
respondJSON(w, 200, map[string]interface{}{
|
||||
"nodes": nodes,
|
||||
"edges": edges,
|
||||
"count": map[string]int{"nodes": len(nodes), "edges": len(edges)},
|
||||
})
|
||||
})
|
||||
|
||||
// 静态文件服务(知识图谱可视化 HTML)
|
||||
staticDir := os.Getenv("STATIC_DIR")
|
||||
if staticDir == "" {
|
||||
staticDir = "/home/muc/projects/memoryweave/go/static"
|
||||
}
|
||||
fileServer := http.FileServer(http.Dir(staticDir))
|
||||
mux.Handle("/static/", http.StripPrefix("/static/", fileServer))
|
||||
|
||||
// 冲突
|
||||
mux.HandleFunc("/api/v1/conflicts", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == "GET" {
|
||||
|
|
|
|||
|
|
@ -239,3 +239,8 @@ func containsRelation(rel, substr string) bool {
|
|||
}
|
||||
return rel == substr
|
||||
}
|
||||
|
||||
// GetGraph 内存图谱的导出(空实现)
|
||||
func (g *InMemoryGraph) GetGraph(namespace string) ([]map[string]interface{}, []map[string]interface{}) {
|
||||
return []map[string]interface{}{}, []map[string]interface{}{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ package governance
|
|||
import "C"
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync"
|
||||
"unsafe"
|
||||
|
|
@ -422,3 +423,54 @@ func escape(s string) string {
|
|||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// GetGraph 返回指定 namespace 的所有节点和边(供可视化用)
|
||||
func (gs *SQLiteGraphStore) GetGraph(namespace string) ([]map[string]interface{}, []map[string]interface{}) {
|
||||
nodes := []map[string]interface{}{}
|
||||
edges := []map[string]interface{}{}
|
||||
|
||||
// 根据 namespace 构建查询(使用 escape() 防注入)
|
||||
var nodeSQL string
|
||||
if namespace == "" || namespace == "all" {
|
||||
nodeSQL = "SELECT id, name, type, namespace, properties FROM graph_nodes"
|
||||
} else {
|
||||
nodeSQL = "SELECT id, name, type, namespace, properties FROM graph_nodes WHERE namespace = '" + escape(namespace) + "'"
|
||||
}
|
||||
|
||||
rows := queryRows(gs.db, nodeSQL)
|
||||
for _, n := range rows {
|
||||
props := n["properties"].(string)
|
||||
var properties map[string]interface{}
|
||||
if props != "" {
|
||||
json.Unmarshal([]byte(props), &properties)
|
||||
}
|
||||
nodes = append(nodes, map[string]interface{}{
|
||||
"id": n["id"],
|
||||
"name": n["name"],
|
||||
"type": n["type"],
|
||||
"namespace": n["namespace"],
|
||||
"properties": properties,
|
||||
})
|
||||
}
|
||||
|
||||
var edgeSQL string
|
||||
if namespace == "" || namespace == "all" {
|
||||
edgeSQL = "SELECT id, source, target, relation, weight, namespace FROM graph_edges"
|
||||
} else {
|
||||
edgeSQL = "SELECT id, source, target, relation, weight, namespace FROM graph_edges WHERE namespace = '" + escape(namespace) + "'"
|
||||
}
|
||||
|
||||
edgeRows := queryRows(gs.db, edgeSQL)
|
||||
for _, e := range edgeRows {
|
||||
edges = append(edges, map[string]interface{}{
|
||||
"id": e["id"],
|
||||
"source": e["source"],
|
||||
"target": e["target"],
|
||||
"relation": e["relation"],
|
||||
"weight": e["weight"],
|
||||
"namespace": e["namespace"],
|
||||
})
|
||||
}
|
||||
|
||||
return nodes, edges
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,4 +26,7 @@ type GraphStore interface {
|
|||
// 多 Agent 分析
|
||||
PageRank(damping float64, iterations int) map[string]float64
|
||||
EvidenceCount(entity string) int
|
||||
|
||||
// 导出完整图谱(供可视化)
|
||||
GetGraph(namespace string) (nodes []map[string]interface{}, edges []map[string]interface{})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,637 @@
|
|||
<!DOCTYPE html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>织忆 MemoryWeave — 知识图谱可视化</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', 'Noto Sans', sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
#header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 56px;
|
||||
background: linear-gradient(180deg, #161b22 0%, #0d1117 100%);
|
||||
border-bottom: 1px solid #30363d;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 24px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#header h1 {
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: #58a6ff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
#header h1 span {
|
||||
color: #8b949e;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
#controls {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
left: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.control-group {
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.control-group label {
|
||||
display: block;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
#namespace-select {
|
||||
background: #0d1117;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 6px;
|
||||
color: #e6edf3;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
min-width: 160px;
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
#namespace-select:hover {
|
||||
border-color: #58a6ff;
|
||||
}
|
||||
|
||||
#namespace-select option {
|
||||
background: #161b22;
|
||||
}
|
||||
|
||||
#stats {
|
||||
position: fixed;
|
||||
top: 72px;
|
||||
right: 24px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
#stats .stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 24px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
#stats .stat-label {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
#stats .stat-value {
|
||||
color: #58a6ff;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
#graph-container {
|
||||
position: fixed;
|
||||
top: 56px;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.node {
|
||||
cursor: pointer;
|
||||
transition: filter 0.2s;
|
||||
}
|
||||
|
||||
.node:hover {
|
||||
filter: brightness(1.3);
|
||||
}
|
||||
|
||||
.node-label {
|
||||
font-size: 10px;
|
||||
fill: #e6edf3;
|
||||
text-anchor: middle;
|
||||
pointer-events: none;
|
||||
text-shadow: 0 1px 2px rgba(0,0,0,0.8);
|
||||
}
|
||||
|
||||
.link {
|
||||
stroke: #30363d;
|
||||
stroke-opacity: 0.6;
|
||||
transition: stroke-opacity 0.2s, stroke 0.2s;
|
||||
}
|
||||
|
||||
.link:hover {
|
||||
stroke-opacity: 1;
|
||||
stroke: #58a6ff;
|
||||
}
|
||||
|
||||
.link.highlighted {
|
||||
stroke: #58a6ff;
|
||||
stroke-opacity: 1;
|
||||
stroke-width: 2px;
|
||||
}
|
||||
|
||||
.link-label {
|
||||
font-size: 9px;
|
||||
fill: #8b949e;
|
||||
text-anchor: middle;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.link-label.visible {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
#tooltip {
|
||||
position: fixed;
|
||||
display: none;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 16px;
|
||||
max-width: 320px;
|
||||
box-shadow: 0 8px 24px rgba(0,0,0,0.4);
|
||||
z-index: 200;
|
||||
}
|
||||
|
||||
#tooltip h3 {
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: #58a6ff;
|
||||
margin-bottom: 8px;
|
||||
padding-bottom: 8px;
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
#tooltip .prop {
|
||||
display: flex;
|
||||
margin: 6px 0;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
#tooltip .prop-key {
|
||||
color: #8b949e;
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
#tooltip .prop-value {
|
||||
color: #e6edf3;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
#tooltip .type-badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
#loading {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #8b949e;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
#error {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
color: #f85149;
|
||||
font-size: 14px;
|
||||
display: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.legend {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 24px;
|
||||
background: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 8px;
|
||||
padding: 12px 16px;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.legend-title {
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #8b949e;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 12px;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.legend-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div id="header">
|
||||
<h1>🧠 织忆 <span>— 知识图谱可视化</span></h1>
|
||||
</div>
|
||||
|
||||
<div id="controls">
|
||||
<div class="control-group">
|
||||
<label>命名空间</label>
|
||||
<select id="namespace-select">
|
||||
<option value="">全部</option>
|
||||
<option value="hermes-main">hermes-main</option>
|
||||
<option value="openclaw-main">openclaw-main</option>
|
||||
<option value="shared">shared</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="stats">
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">节点</span>
|
||||
<span class="stat-value" id="node-count">0</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">边</span>
|
||||
<span class="stat-value" id="edge-count">0</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="graph-container"></div>
|
||||
|
||||
<div class="legend">
|
||||
<div class="legend-title">节点类型</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#58a6ff"></span>entity</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#3fb950"></span>fact</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#d29922"></span>decision</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#a371f7"></span>skill</div>
|
||||
<div class="legend-item"><span class="legend-dot" style="background:#f85149"></span>event</div>
|
||||
</div>
|
||||
|
||||
<div id="tooltip"></div>
|
||||
<div id="loading">加载中...</div>
|
||||
<div id="error"></div>
|
||||
|
||||
<script>
|
||||
const TYPE_COLORS = {
|
||||
entity: '#58a6ff',
|
||||
fact: '#3fb950',
|
||||
decision: '#d29922',
|
||||
skill: '#a371f7',
|
||||
event: '#f85149'
|
||||
};
|
||||
|
||||
const API_BASE = 'http://localhost:7821';
|
||||
const API_KEY = 'zhiyi-dev-key-2026';
|
||||
let nodes = [], links = [];
|
||||
let simulation, svg, g, linkGroup, nodeGroup, labelGroup;
|
||||
let width, height;
|
||||
let currentHighlight = null;
|
||||
|
||||
function formatValue(val) {
|
||||
if (val === null || val === undefined) return '—';
|
||||
if (typeof val === 'object') return JSON.stringify(val);
|
||||
return String(val);
|
||||
}
|
||||
|
||||
async function loadGraph(namespace = '') {
|
||||
const loading = document.getElementById('loading');
|
||||
const error = document.getElementById('error');
|
||||
loading.style.display = 'block';
|
||||
error.style.display = 'none';
|
||||
|
||||
try {
|
||||
const url = namespace
|
||||
? `${API_BASE}/api/v1/graph/export?namespace=${encodeURIComponent(namespace)}`
|
||||
: `${API_BASE}/api/v1/graph/export`;
|
||||
|
||||
const resp = await fetch(url, {
|
||||
headers: { 'X-API-Key': API_KEY }
|
||||
});
|
||||
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
|
||||
|
||||
const data = await resp.json();
|
||||
nodes = data.nodes || [];
|
||||
links = data.edges || [];
|
||||
|
||||
document.getElementById('node-count').textContent = nodes.length;
|
||||
document.getElementById('edge-count').textContent = links.length;
|
||||
|
||||
loading.style.display = 'none';
|
||||
renderGraph();
|
||||
} catch (e) {
|
||||
loading.style.display = 'none';
|
||||
error.style.display = 'block';
|
||||
error.textContent = `加载失败: ${e.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
function initSVG() {
|
||||
const container = document.getElementById('graph-container');
|
||||
width = container.clientWidth;
|
||||
height = container.clientHeight;
|
||||
|
||||
svg = d3.select('#graph-container')
|
||||
.append('svg')
|
||||
.attr('width', width)
|
||||
.attr('height', height);
|
||||
|
||||
// 背景网格
|
||||
const defs = svg.append('defs');
|
||||
const pattern = defs.append('pattern')
|
||||
.attr('id', 'grid')
|
||||
.attr('width', 40)
|
||||
.attr('height', 40)
|
||||
.attr('patternUnits', 'userSpaceOnUse');
|
||||
|
||||
pattern.append('circle')
|
||||
.attr('cx', 20).attr('cy', 20).attr('r', 1)
|
||||
.attr('fill', '#21262d');
|
||||
|
||||
svg.append('rect')
|
||||
.attr('width', '100%').attr('height', '100%')
|
||||
.attr('fill', 'url(#grid)');
|
||||
|
||||
g = svg.append('g');
|
||||
linkGroup = g.append('g').attr('class', 'links');
|
||||
labelGroup = g.append('g').attr('class', 'link-labels');
|
||||
nodeGroup = g.append('g').attr('class', 'nodes');
|
||||
|
||||
// 缩放
|
||||
svg.call(d3.zoom()
|
||||
.scaleExtent([0.1, 4])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform);
|
||||
}));
|
||||
}
|
||||
|
||||
function renderGraph() {
|
||||
linkGroup.selectAll('*').remove();
|
||||
labelGroup.selectAll('*').remove();
|
||||
nodeGroup.selectAll('*').remove();
|
||||
|
||||
if (simulation) simulation.stop();
|
||||
|
||||
// 计算节点大小
|
||||
const maxPagerank = Math.max(...nodes.map(n => n.pagerank || 0), 1);
|
||||
const nodeSize = d3.scaleSqrt()
|
||||
.domain([0, maxPagerank])
|
||||
.range([4, 20]);
|
||||
|
||||
// 创建力模拟
|
||||
simulation = d3.forceSimulation(nodes)
|
||||
.force('link', d3.forceLink(links).id(d => d.id).distance(100))
|
||||
.force('charge', d3.forceManyBody().strength(-300))
|
||||
.force('center', d3.forceCenter(width / 2, height / 2))
|
||||
.force('collision', d3.forceCollide().radius(d => nodeSize(d.pagerank || 0) + 5));
|
||||
|
||||
// 绘制边
|
||||
const link = linkGroup.selectAll('line')
|
||||
.data(links)
|
||||
.enter().append('line')
|
||||
.attr('class', 'link')
|
||||
.attr('stroke-width', d => Math.sqrt(d.weight || 1));
|
||||
|
||||
// 边标签
|
||||
const linkLabel = labelGroup.selectAll('text')
|
||||
.data(links)
|
||||
.enter().append('text')
|
||||
.attr('class', 'link-label')
|
||||
.text(d => d.relation || '');
|
||||
|
||||
// 绘制节点
|
||||
const node = nodeGroup.selectAll('g')
|
||||
.data(nodes)
|
||||
.enter().append('g')
|
||||
.attr('class', 'node')
|
||||
.call(d3.drag()
|
||||
.on('start', dragstarted)
|
||||
.on('drag', dragged)
|
||||
.on('end', dragended));
|
||||
|
||||
node.append('circle')
|
||||
.attr('r', d => nodeSize(d.pagerank || 0))
|
||||
.attr('fill', d => TYPE_COLORS[d.type] || '#8b949e')
|
||||
.attr('stroke', '#0d1117')
|
||||
.attr('stroke-width', 2);
|
||||
|
||||
node.append('text')
|
||||
.attr('class', 'node-label')
|
||||
.attr('dy', d => nodeSize(d.pagerank || 0) + 14)
|
||||
.text(d => d.name || d.id);
|
||||
|
||||
// 事件
|
||||
node.on('mouseover', function(event, d) {
|
||||
highlightNeighbors(d);
|
||||
showLinkLabels(true);
|
||||
})
|
||||
.on('mouseout', function() {
|
||||
clearHighlight();
|
||||
showLinkLabels(false);
|
||||
})
|
||||
.on('click', function(event, d) {
|
||||
event.stopPropagation();
|
||||
showTooltip(d, event);
|
||||
});
|
||||
|
||||
link.on('mouseover', function(event, d) {
|
||||
d3.select(this).classed('highlighted', true);
|
||||
showLinkLabels(true, d);
|
||||
})
|
||||
.on('mouseout', function() {
|
||||
d3.select(this).classed('highlighted', false);
|
||||
showLinkLabels(false);
|
||||
});
|
||||
|
||||
svg.on('click', () => hideTooltip());
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', d => d.source.x)
|
||||
.attr('y1', d => d.source.y)
|
||||
.attr('x2', d => d.target.x)
|
||||
.attr('y2', d => d.target.y);
|
||||
|
||||
linkLabel
|
||||
.attr('x', d => (d.source.x + d.target.x) / 2)
|
||||
.attr('y', d => (d.source.y + d.target.y) / 2);
|
||||
|
||||
node.attr('transform', d => `translate(${d.x},${d.y})`);
|
||||
});
|
||||
}
|
||||
|
||||
function highlightNeighbors(node) {
|
||||
currentHighlight = node;
|
||||
const neighborIds = new Set([node.id]);
|
||||
|
||||
links.forEach(l => {
|
||||
const srcId = typeof l.source === 'object' ? l.source.id : l.source;
|
||||
const tgtId = typeof l.target === 'object' ? l.target.id : l.target;
|
||||
if (srcId === node.id) neighborIds.add(tgtId);
|
||||
if (tgtId === node.id) neighborIds.add(srcId);
|
||||
});
|
||||
|
||||
nodeGroup.selectAll('.node').style('opacity', d =>
|
||||
neighborIds.has(d.id) ? 1 : 0.2
|
||||
);
|
||||
|
||||
linkGroup.selectAll('line').style('opacity', d => {
|
||||
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
||||
return srcId === node.id || tgtId === node.id ? 1 : 0.1;
|
||||
});
|
||||
}
|
||||
|
||||
function clearHighlight() {
|
||||
currentHighlight = null;
|
||||
nodeGroup.selectAll('.node').style('opacity', 1);
|
||||
linkGroup.selectAll('line').style('opacity', 1);
|
||||
}
|
||||
|
||||
function showLinkLabels(visible, specificLink = null) {
|
||||
labelGroup.selectAll('text')
|
||||
.classed('visible', d => {
|
||||
if (!visible) return false;
|
||||
if (specificLink) return d === specificLink;
|
||||
if (currentHighlight) {
|
||||
const srcId = typeof d.source === 'object' ? d.source.id : d.source;
|
||||
const tgtId = typeof d.target === 'object' ? d.target.id : d.target;
|
||||
return srcId === currentHighlight.id || tgtId === currentHighlight.id;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function showTooltip(node, event) {
|
||||
const tooltip = document.getElementById('tooltip');
|
||||
const typeColor = TYPE_COLORS[node.type] || '#8b949e';
|
||||
|
||||
let html = `
|
||||
<h3>${formatValue(node.name || node.id)}</h3>
|
||||
<div class="prop">
|
||||
<span class="prop-key">ID</span>
|
||||
<span class="prop-value">${formatValue(node.id)}</span>
|
||||
</div>
|
||||
<div class="prop">
|
||||
<span class="prop-key">类型</span>
|
||||
<span class="prop-value">${formatValue(node.type)}</span>
|
||||
</div>
|
||||
<div class="prop">
|
||||
<span class="prop-key">命名空间</span>
|
||||
<span class="prop-value">${formatValue(node.namespace)}</span>
|
||||
</div>
|
||||
<div class="prop">
|
||||
<span class="prop-key">PageRank</span>
|
||||
<span class="prop-value">${((node.pagerank || 0) * 100).toFixed(2)}%</span>
|
||||
</div>
|
||||
`;
|
||||
|
||||
if (node.properties && typeof node.properties === 'object') {
|
||||
html += '<div style="margin-top:8px;border-top:1px solid #30363d;padding-top:8px;">';
|
||||
for (const [k, v] of Object.entries(node.properties)) {
|
||||
html += `<div class="prop">
|
||||
<span class="prop-key">${formatValue(k)}</span>
|
||||
<span class="prop-value">${formatValue(v)}</span>
|
||||
</div>`;
|
||||
}
|
||||
html += '</div>';
|
||||
}
|
||||
|
||||
html += `<span class="type-badge" style="background:${typeColor}20;color:${typeColor}">${formatValue(node.type)}</span>`;
|
||||
|
||||
tooltip.innerHTML = html;
|
||||
tooltip.style.display = 'block';
|
||||
tooltip.style.left = Math.min(event.clientX + 16, window.innerWidth - 340) + 'px';
|
||||
tooltip.style.top = Math.min(event.clientY + 16, window.innerHeight - tooltip.offsetHeight - 20) + 'px';
|
||||
}
|
||||
|
||||
function hideTooltip() {
|
||||
document.getElementById('tooltip').style.display = 'none';
|
||||
}
|
||||
|
||||
function dragstarted(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart();
|
||||
d.fx = d.x;
|
||||
d.fy = d.y;
|
||||
}
|
||||
|
||||
function dragged(event, d) {
|
||||
d.fx = event.x;
|
||||
d.fy = event.y;
|
||||
}
|
||||
|
||||
function dragended(event, d) {
|
||||
if (!event.active) simulation.alphaTarget(0);
|
||||
d.fx = null;
|
||||
d.fy = null;
|
||||
}
|
||||
|
||||
// 初始化
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
initSVG();
|
||||
loadGraph();
|
||||
|
||||
document.getElementById('namespace-select').addEventListener('change', (e) => {
|
||||
loadGraph(e.target.value);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
const container = document.getElementById('graph-container');
|
||||
width = container.clientWidth;
|
||||
height = container.clientHeight;
|
||||
svg.attr('width', width).attr('height', height);
|
||||
if (simulation) {
|
||||
simulation.force('center', d3.forceCenter(width / 2, height / 2));
|
||||
simulation.alpha(0.3).restart();
|
||||
}
|
||||
});
|
||||
});
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue