chore: use d3

This commit is contained in:
Soulter
2025-05-18 16:43:47 +08:00
parent 0f64981b20
commit d2379da478
3 changed files with 328 additions and 368 deletions
+1 -3
View File
@@ -20,16 +20,14 @@
"axios": "^1.6.2",
"axios-mock-adapter": "^1.22.0",
"chance": "1.1.11",
"d3": "^7.9.0",
"date-fns": "2.30.0",
"graphology": "^0.26.0",
"graphology-layout-force": "^0.2.4",
"highlight.js": "^11.11.1",
"js-md5": "^0.8.3",
"lodash": "4.17.21",
"marked": "^15.0.7",
"pinia": "2.1.6",
"remixicon": "3.5.0",
"sigma": "^3.0.1",
"vee-validate": "4.11.3",
"vite-plugin-vuetify": "1.0.2",
"vue": "3.3.4",
+182 -145
View File
@@ -1,7 +1,6 @@
<script setup>
import Graph from "graphology";
import Sigma from "sigma";
import ForceSupervisor from "graphology-layout-force/worker";
// 在较庞大的图下,d3 的性能不如 sigma.js 渲染库,因此我们优先使用 sigma.js 来渲染图。
import * as d3 from "d3"; // npm install d3
</script>
@@ -142,12 +141,14 @@ export default {
},
data() {
return {
renderer: null,
graph: null,
layout: null,
simulation: null,
svg: null,
zoom: null,
activeTab: 'long-term-memory',
node_data: [],
edge_data: [],
nodes: [],
links: [],
searchUserId: null,
userIdList: [],
selectedNode: null,
@@ -167,34 +168,31 @@ export default {
}
},
mounted() {
this.initSigma();
this.initD3Graph();
this.ltmGetGraph();
this.ltmGetUserIds();
},
beforeUnmount() {
if (this.renderer) {
this.renderer.kill();
}
if (this.layout) {
this.layout.stop();
if (this.simulation) {
this.simulation.stop();
}
},
watch: {
activeTab(newVal) {
if (newVal === 'long-term-memory') {
this.$nextTick(() => {
if (!this.renderer) {
this.initSigma();
if (!this.svg) {
this.initD3Graph();
}
});
} else {
if (this.renderer) {
this.renderer.kill();
this.renderer = null;
if (this.simulation) {
this.simulation.stop();
this.simulation = null;
}
if (this.layout) {
this.layout.stop();
this.layout = null;
if (this.svg) {
d3.select("#graph-container svg").remove();
this.svg = null;
}
}
}
@@ -206,62 +204,46 @@ export default {
axios.get('/api/plug/alkaid/ltm/graph', { params })
.then(response => {
let nodes = response.data.data.nodes;
let edges = response.data.data.edges;
let nodesRaw = response.data.data.nodes;
let edgesRaw = response.data.data.edges;
this.node_data = nodes;
this.edge_data = edges;
this.node_data = nodesRaw;
this.edge_data = edgesRaw;
if (this.graph) {
this.graph.clear();
}
nodes.forEach(node => {
// 转换为D3所需的数据格式
this.nodes = nodesRaw.map(node => {
const nodeId = node[0];
const nodeData = node[1];
if (!this.graph.hasNode(nodeId)) {
const nodeType = nodeData._label || 'default';
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
this.graph.addNode(nodeId, {
x: Math.random(),
y: Math.random(),
size: 5,
label: nodeData.name || nodeId.split('_')[0],
color: color,
originalData: nodeData
});
}
const nodeType = nodeData._label || 'default';
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
return {
id: nodeId,
label: nodeData.name || nodeId.split('_')[0],
color: color,
originalData: nodeData
};
});
// 添加边
edges.forEach(edge => {
this.links = edgesRaw.map(edge => {
const sourceId = edge[0];
const targetId = edge[1];
const edgeData = edge[2];
if (this.graph.hasNode(sourceId) && this.graph.hasNode(targetId)) {
const edgeId = `${sourceId}->${targetId}`;
const relationType = edgeData.relation_type || 'default';
const color = this.edgeColors[relationType] || this.edgeColors['default'];
this.graph.addEdge(sourceId, targetId, {
size: 1,
color: color,
originalData: edgeData,
label: relationType,
type: "line"
});
} else {
console.warn(`Edge ${sourceId} -> ${targetId} has missing nodes.`);
}
const relationType = edgeData.relation_type || 'default';
const color = this.edgeColors[relationType] || this.edgeColors['default'];
return {
source: sourceId,
target: targetId,
color: color,
originalData: edgeData,
label: relationType
};
});
this.updateD3Graph();
this.updateGraphStats();
console.log('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
console.log('Graph initialized with', this.nodes.length, 'nodes and', this.links.length, 'links');
})
.catch(error => {
console.error('Error fetching graph data:', error);
@@ -269,11 +251,6 @@ export default {
.finally(() => {
this.isLoading = false;
});
if (this.layout) {
this.layout.start();
}
},
ltmGetUserIds() {
@@ -287,12 +264,10 @@ export default {
},
updateGraphStats() {
if (this.graph) {
this.graphStats = {
nodeCount: this.graph.order,
edgeCount: this.graph.size
};
}
this.graphStats = {
nodeCount: this.nodes.length,
edgeCount: this.links.length
};
},
refreshGraph() {
@@ -301,7 +276,7 @@ export default {
onNodeSelect() {
console.log('Selected user ID:', this.searchUserId);
if (!this.searchUserId || !this.graph) return;
if (!this.searchUserId) return;
// 使用API的user_id参数筛选数据
this.ltmGetGraph(this.searchUserId);
@@ -312,83 +287,136 @@ export default {
this.ltmGetGraph();
},
initSigma() {
initD3Graph() {
const container = document.getElementById("graph-container");
if (!container) return;
d3.select("#graph-container svg").remove();
const width = container.clientWidth;
const height = container.clientHeight;
const svg = d3.select("#graph-container")
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", [0, 0, width, height])
.classed("d3-graph", true);
const g = svg.append("g");
const zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
if (this.renderer) {
this.renderer.kill();
this.renderer = null;
}
if (this.layout) {
this.layout.stop();
this.layout = null;
}
svg.call(zoom);
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().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(30));
const graph = new Graph({
multi: true,
this.svg = svg;
this.g = g;
this.zoom = zoom;
this.simulation = simulation;
this.width = width;
this.height = height;
},
updateD3Graph() {
if (!this.svg || !this.simulation) return;
const g = this.g;
g.selectAll("*").remove();
g.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20)
.attr("refY", 0)
.attr("orient", "auto")
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#999");
const link = g.append("g")
.selectAll("line")
.data(this.links)
.join("line")
.attr("stroke", d => d.color)
.attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowhead)");
const edgeLabels = g.append("g")
.selectAll("text")
.data(this.links)
.join("text")
.text(d => d.label)
.attr("font-size", "8px")
.attr("text-anchor", "middle")
.attr("fill", "#666")
.attr("dy", -5);
const node = g.append("g")
.selectAll("circle")
.data(this.nodes)
.join("circle")
.attr("r", 8)
.attr("fill", d => d.color)
.style("cursor", "pointer")
.call(this.dragBehavior());
const nodeLabels = g.append("g")
.selectAll("text")
.data(this.nodes)
.join("text")
.text(d => d.label)
.attr("font-size", "10px")
.attr("text-anchor", "middle")
.attr("fill", "#333")
.attr("dy", -12);
node.on("click", (event, d) => {
event.stopPropagation();
this.selectedNode = d.originalData;
});
const layout = new ForceSupervisor(graph, {
isNodeFixed: (_, attr) => attr.highlighted, settings: {
barnesHutOptimize: true,
}
});
layout.start();
this.layout = layout;
this.graph = graph;
const renderer = new Sigma(graph, container, {
minCameraRatio: 0.01,
maxCameraRatio: 2,
labelRenderedSizeThreshold: 1,
renderLabels: true,
renderEdgeLabels: true,
labelSize: 14,
labelColor: "#333333",
});
this.renderer = renderer;
let draggedNode = null;
let isDragging = false;
renderer.on("downNode", (e) => {
isDragging = true;
draggedNode = e.node;
graph.setNodeAttribute(draggedNode, "highlighted", true);
if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox());
});
renderer.on("moveBody", ({ event }) => {
if (!isDragging || !draggedNode) return;
const pos = renderer.viewportToGraph(event);
graph.setNodeAttribute(draggedNode, "x", pos.x);
graph.setNodeAttribute(draggedNode, "y", pos.y);
event.preventSigmaDefault();
event.original.preventDefault();
event.original.stopPropagation();
});
const handleUp = () => {
if (draggedNode) {
graph.removeNodeAttribute(draggedNode, "highlighted");
}
isDragging = false;
draggedNode = null;
};
renderer.on("upNode", handleUp);
renderer.on("upStage", handleUp);
renderer.on("clickNode", (e) => {
const nodeId = e.node;
const nodeAttributes = graph.getNodeAttributes(nodeId);
this.selectedNode = nodeAttributes.originalData;
});
renderer.on("clickStage", () => {
this.svg.on("click", () => {
this.selectedNode = null;
});
this.simulation
.nodes(this.nodes)
.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);
edgeLabels
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
nodeLabels
.attr("x", d => d.x)
.attr("y", d => d.y);
});
this.simulation.force("link")
.links(this.links);
this.simulation.alpha(1).restart();
},
dragBehavior() {
return d3.drag()
.on("start", (event, d) => {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
});
},
getRandomColor() {
@@ -428,4 +456,13 @@ export default {
.memory-header {
padding: 0 8px;
}
#graph-container svg {
width: 100%;
height: 100%;
}
.d3-graph {
background-color: #f2f6f9;
}
</style>
@@ -1,6 +1,7 @@
<script setup>
// d3 sigma.js 使 sigma.js
import * as d3 from "d3"; // npm install d3
import Graph from "graphology";
import Sigma from "sigma";
import ForceSupervisor from "graphology-layout-force/worker";
</script>
@@ -141,14 +142,12 @@ export default {
},
data() {
return {
simulation: null,
svg: null,
zoom: null,
renderer: null,
graph: null,
layout: null,
activeTab: 'long-term-memory',
node_data: [],
edge_data: [],
nodes: [],
links: [],
searchUserId: null,
userIdList: [],
selectedNode: null,
@@ -168,31 +167,34 @@ export default {
}
},
mounted() {
this.initD3Graph();
this.initSigma();
this.ltmGetGraph();
this.ltmGetUserIds();
},
beforeUnmount() {
if (this.simulation) {
this.simulation.stop();
if (this.renderer) {
this.renderer.kill();
}
if (this.layout) {
this.layout.stop();
}
},
watch: {
activeTab(newVal) {
if (newVal === 'long-term-memory') {
this.$nextTick(() => {
if (!this.svg) {
this.initD3Graph();
if (!this.renderer) {
this.initSigma();
}
});
} else {
if (this.simulation) {
this.simulation.stop();
this.simulation = null;
if (this.renderer) {
this.renderer.kill();
this.renderer = null;
}
if (this.svg) {
d3.select("#graph-container svg").remove();
this.svg = null;
if (this.layout) {
this.layout.stop();
this.layout = null;
}
}
}
@@ -204,46 +206,62 @@ export default {
axios.get('/api/plug/alkaid/ltm/graph', { params })
.then(response => {
let nodesRaw = response.data.data.nodes;
let edgesRaw = response.data.data.edges;
let nodes = response.data.data.nodes;
let edges = response.data.data.edges;
this.node_data = nodesRaw;
this.edge_data = edgesRaw;
this.node_data = nodes;
this.edge_data = edges;
// D3
this.nodes = nodesRaw.map(node => {
if (this.graph) {
this.graph.clear();
}
nodes.forEach(node => {
const nodeId = node[0];
const nodeData = node[1];
const nodeType = nodeData._label || 'default';
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
return {
id: nodeId,
label: nodeData.name || nodeId.split('_')[0],
color: color,
originalData: nodeData
};
if (!this.graph.hasNode(nodeId)) {
const nodeType = nodeData._label || 'default';
const color = this.nodeColors[nodeType] || this.nodeColors['default'];
this.graph.addNode(nodeId, {
x: Math.random(),
y: Math.random(),
size: 5,
label: nodeData.name || nodeId.split('_')[0],
color: color,
originalData: nodeData
});
}
});
this.links = edgesRaw.map(edge => {
//
edges.forEach(edge => {
const sourceId = edge[0];
const targetId = edge[1];
const edgeData = edge[2];
const relationType = edgeData.relation_type || 'default';
const color = this.edgeColors[relationType] || this.edgeColors['default'];
return {
source: sourceId,
target: targetId,
color: color,
originalData: edgeData,
label: relationType
};
if (this.graph.hasNode(sourceId) && this.graph.hasNode(targetId)) {
const edgeId = `${sourceId}->${targetId}`;
const relationType = edgeData.relation_type || 'default';
const color = this.edgeColors[relationType] || this.edgeColors['default'];
this.graph.addEdge(sourceId, targetId, {
size: 1,
color: color,
originalData: edgeData,
label: relationType,
type: "line"
});
} else {
console.warn(`Edge ${sourceId} -> ${targetId} has missing nodes.`);
}
});
this.updateD3Graph();
this.updateGraphStats();
console.log('Graph initialized with', this.nodes.length, 'nodes and', this.links.length, 'links');
console.log('Graph initialized with', nodes.length, 'nodes and', edges.length, 'edges');
})
.catch(error => {
console.error('Error fetching graph data:', error);
@@ -251,6 +269,11 @@ export default {
.finally(() => {
this.isLoading = false;
});
if (this.layout) {
this.layout.start();
}
},
ltmGetUserIds() {
@@ -264,10 +287,12 @@ export default {
},
updateGraphStats() {
this.graphStats = {
nodeCount: this.nodes.length,
edgeCount: this.links.length
};
if (this.graph) {
this.graphStats = {
nodeCount: this.graph.order,
edgeCount: this.graph.size
};
}
},
refreshGraph() {
@@ -276,7 +301,7 @@ export default {
onNodeSelect() {
console.log('Selected user ID:', this.searchUserId);
if (!this.searchUserId) return;
if (!this.searchUserId || !this.graph) return;
// 使APIuser_id
this.ltmGetGraph(this.searchUserId);
@@ -287,175 +312,84 @@ export default {
this.ltmGetGraph();
},
initD3Graph() {
initSigma() {
const container = document.getElementById("graph-container");
if (!container) return;
// SVG
d3.select("#graph-container svg").remove();
if (this.renderer) {
this.renderer.kill();
this.renderer = null;
}
if (this.layout) {
this.layout.stop();
this.layout = null;
}
//
const width = container.clientWidth;
const height = container.clientHeight;
// SVG
const svg = d3.select("#graph-container")
.append("svg")
.attr("width", "100%")
.attr("height", "100%")
.attr("viewBox", [0, 0, width, height])
.classed("d3-graph", true);
//
const g = svg.append("g");
//
const zoom = d3.zoom()
.scaleExtent([0.1, 10])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
//
const simulation = d3.forceSimulation()
.force("link", d3.forceLink().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(30));
this.svg = svg;
this.g = g;
this.zoom = zoom;
this.simulation = simulation;
this.width = width;
this.height = height;
},
updateD3Graph() {
if (!this.svg || !this.simulation) return;
const g = this.g;
//
g.selectAll("*").remove();
//
g.append("defs").append("marker")
.attr("id", "arrowhead")
.attr("viewBox", "0 -5 10 10")
.attr("refX", 20)
.attr("refY", 0)
.attr("orient", "auto")
.attr("markerWidth", 6)
.attr("markerHeight", 6)
.append("path")
.attr("d", "M0,-5L10,0L0,5")
.attr("fill", "#999");
//
const link = g.append("g")
.selectAll("line")
.data(this.links)
.join("line")
.attr("stroke", d => d.color)
.attr("stroke-width", 1.5)
.attr("marker-end", "url(#arrowhead)");
//
const edgeLabels = g.append("g")
.selectAll("text")
.data(this.links)
.join("text")
.text(d => d.label)
.attr("font-size", "8px")
.attr("text-anchor", "middle")
.attr("fill", "#666")
.attr("dy", -5);
//
const node = g.append("g")
.selectAll("circle")
.data(this.nodes)
.join("circle")
.attr("r", 8)
.attr("fill", d => d.color)
.style("cursor", "pointer")
.call(this.dragBehavior());
//
const nodeLabels = g.append("g")
.selectAll("text")
.data(this.nodes)
.join("text")
.text(d => d.label)
.attr("font-size", "10px")
.attr("text-anchor", "middle")
.attr("fill", "#333")
.attr("dy", -12);
//
node.on("click", (event, d) => {
event.stopPropagation();
this.selectedNode = d.originalData;
const graph = new Graph({
multi: true,
});
//
this.svg.on("click", () => {
const layout = new ForceSupervisor(graph, {
isNodeFixed: (_, attr) => attr.highlighted, settings: {
gravity: 0.0001,
repulsion: 0.001
}
});
layout.start();
this.layout = layout;
this.graph = graph;
const renderer = new Sigma(graph, container, {
minCameraRatio: 0.01,
maxCameraRatio: 2,
labelRenderedSizeThreshold: 1,
renderLabels: true,
renderEdgeLabels: true,
labelSize: 14,
labelColor: "#333333",
});
this.renderer = renderer;
let draggedNode = null;
let isDragging = false;
renderer.on("downNode", (e) => {
isDragging = true;
draggedNode = e.node;
graph.setNodeAttribute(draggedNode, "highlighted", true);
if (!renderer.getCustomBBox()) renderer.setCustomBBox(renderer.getBBox());
});
renderer.on("moveBody", ({ event }) => {
if (!isDragging || !draggedNode) return;
const pos = renderer.viewportToGraph(event);
graph.setNodeAttribute(draggedNode, "x", pos.x);
graph.setNodeAttribute(draggedNode, "y", pos.y);
event.preventSigmaDefault();
event.original.preventDefault();
event.original.stopPropagation();
});
const handleUp = () => {
if (draggedNode) {
graph.removeNodeAttribute(draggedNode, "highlighted");
}
isDragging = false;
draggedNode = null;
};
renderer.on("upNode", handleUp);
renderer.on("upStage", handleUp);
renderer.on("clickNode", (e) => {
const nodeId = e.node;
const nodeAttributes = graph.getNodeAttributes(nodeId);
this.selectedNode = nodeAttributes.originalData;
});
renderer.on("clickStage", () => {
this.selectedNode = null;
});
//
this.simulation
.nodes(this.nodes)
.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);
//
edgeLabels
.attr("x", d => (d.source.x + d.target.x) / 2)
.attr("y", d => (d.source.y + d.target.y) / 2);
//
node
.attr("cx", d => d.x)
.attr("cy", d => d.y);
//
nodeLabels
.attr("x", d => d.x)
.attr("y", d => d.y);
});
this.simulation.force("link")
.links(this.links);
//
this.simulation.alpha(1).restart();
},
dragBehavior() {
return d3.drag()
.on("start", (event, d) => {
if (!event.active) this.simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
})
.on("drag", (event, d) => {
d.fx = event.x;
d.fy = event.y;
})
.on("end", (event, d) => {
if (!event.active) this.simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
});
},
getRandomColor() {
@@ -495,13 +429,4 @@ export default {
.memory-header {
padding: 0 8px;
}
#graph-container svg {
width: 100%;
height: 100%;
}
.d3-graph {
background-color: #f2f6f9;
}
</style>