sigma.js 怎么实现节点点击、拖拽与悬停提示交互
深入解析在 sigma.js 中如何基于 Graphology 实现高性能的图谱节点点击高亮、平滑拖拽移动以及 HTML 浮动悬停提示(Tooltip)交互,涵盖事件监听、坐标转换与镜头状态控制的核心代码与实践技巧。
一、核心认知:数据层与 WebGL 渲染层的分离机制
在编写交互代码前,必须先厘清现代 sigma.js(v2 及以上版本)的核心架构设计。以往的轻量级 DOM/Canvas 库通常允许开发者直接操作图元的样式与坐标,但 sigma.js 严格遵循“数据模型与渲染管线解耦”的设计理念:
- Graphology(图数据模型):专门负责维护图拓扑关系(节点、边、权重、标签及空间坐标
x, y)。它是纯 JavaScript 内存对象,不包含任何浏览器渲染逻辑。 - Sigma Renderer(WebGL 渲染引擎):负责将 Graphology 中的抽象拓扑映射为 GPU 顶点和片段着色器,控制视图的缩放(zoom)、平移(pan)和旋转,并通过
MouseCaptor捕获画布事件。
这意味着:你不能通过操作 DOM 样式来改变图谱中的节点,所有的交互逻辑本质上都是“事件触发 → 计算状态或坐标 → 更新 Graphology 属性或 Camera 镜头 → WebGL 自动重绘”的过程。
如图 1 所示,三种核心交互对应着不同的流转通道:
- 节点点击(Click):通过监听
clickNode事件,获取当前节点 ID,既可以通过相机的animate()方法平滑聚焦,也可以结合nodeReducer实现一阶邻居的高亮筛选。 - 节点拖拽(Drag):拖拽需要处理屏幕像素坐标系到图谱世界坐标系的逆向换算。必须通过
renderer.viewportToGraph()将鼠标指针位置转换为节点在拓扑中的新x, y,并在拖拽过程中锁定相机平移。 - 悬停提示(Tooltip):WebGL 无法直接渲染富文本 HTML 标签。标准的做法是在 WebGL 画布上方叠加一个绝对定位的 HTML
<div>,在触发enterNode时利用renderer.graphToViewport()将节点位置正向投影为屏幕像素坐标,动态赋值给浮层的left和top。
二、环境搭建:最小可用图谱骨架
为了保证交互代码直观清晰,我们首先搭建一个最小的基础环境。页面由一个全屏的画布容器和一个绝对定位的 Tooltip 浮层组成,通过 ESM 或 CDN 引入 graphology 与 sigma。
<!-- index.html -->
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Sigma.js 交互示例</title>
<style>
html, body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
}
#sigma-container {
width: 100%;
height: 100%;
background: #FAF7F1;
}
/* 悬停提示浮层:默认隐藏,绝对定位 */
#tooltip {
position: absolute;
display: none;
padding: 8px 12px;
background: #394957;
color: #FAF7F1;
font-size: 12px;
border-radius: 6px;
pointer-events: none; /* 防止鼠标指针阻挡底层画布交互 */
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
z-index: 100;
white-space: nowrap;
transform: translate(12px, -50%); /* 偏移光标右侧 */
transition: opacity 0.15s ease;
}
</style>
<!-- 引入 Graphology 与 Sigma 脚本库 -->
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/dist/sigma.min.js"></script>
</head>
<body>
<div id="sigma-container"></div>
<div id="tooltip"></div>
<script>
const { Graph } = window.graphology;
const Sigma = window.Sigma;
// 1. 初始化图数据模型
const graph = new Graph();
// 2. 构造演示数据:环形分布的节点与关联边
const NODES_COUNT = 10;
for (let i = 0; i < NODES_COUNT; i++) {
const angle = (i * 2 * Math.PI) / NODES_COUNT;
graph.addNode(`node-${i}`, {
x: Math.cos(angle) * 10,
y: Math.sin(angle) * 10,
size: 14,
label: `实体节点 #${i + 1}`,
color: "#394957",
category: i % 2 === 0 ? "核心系统" : "基础依赖"
});
}
// 添加若干连接边
for (let i = 0; i < NODES_COUNT; i++) {
graph.addEdge(`node-${i}`, `node-${(i + 1) % NODES_COUNT}`, {
size: 2,
color: "#E5DFD3"
});
if (i % 2 === 0) {
graph.addEdge(`node-${i}`, `node-${(i + 3) % NODES_COUNT}`, {
size: 1.5,
color: "#E5DFD3"
});
}
}
// 3. 实例化 Sigma 渲染器
const container = document.getElementById("sigma-container");
const renderer = new Sigma(graph, container, {
renderEdgeLabels: false,
allowInvalidContainer: true
});
</script>
</body>
</html>
x 和 y 是必填字段。它们定义了节点在拓扑虚拟空间中的分布位置,无需与屏幕分辨率画素直接对应。
三、实现节点点击交互:邻居高亮与镜头平滑对焦
点击节点时,用户通常期望看到两项视觉反馈:一是该节点平滑移动到屏幕视觉正中心(视线聚焦);二是突出显示该节点及其直接相连的一阶邻居,同时弱化背景中无关的节点和连线。
1. 利用 Camera API 居中对焦
Sigma 实例内置了一个 Camera 控制器,它提供了 animate() 方法。当用户点击节点时,我们读取该节点在图谱空间中的坐标,命令相机平滑飞渡至对应中心:
// 监听节点点击事件
renderer.on("clickNode", ({ node }) => {
const nodePosition = renderer.getNodeDisplayData(node);
// 镜头动画移动至该节点位置
renderer.getCamera().animate(
{
x: nodePosition.x,
y: nodePosition.y,
ratio: 0.6 // 适当放大镜头
},
{
duration: 500 // 动画耗时 500 毫秒
}
);
});
2. 使用 nodeReducer 和 edgeReducer 实现无损高亮
初学者最容易犯的错误是:在点击事件中循环调用 graph.setNodeAttribute() 将全图无关节点的颜色一一改灰。这种做法存在两个致命问题:一是污染了底层原始数据,取消高亮时必须再次循环全图还原;二是大数据量下每次修改都会触发局部重构,性能急剧下降。
推荐最佳实践:使用 Sigma 提供的 nodeReducer 和 edgeReducer。Reducer 就像显卡的着色过滤器,在每一帧渲染时动态判定节点样式,而完全不修改 Graphology 内部的数据:
// 记录当前选中的节点状态
let selectedNode = null;
let neighbors = new Set();
// 点击节点激活高亮
renderer.on("clickNode", ({ node }) => {
if (selectedNode === node) {
// 再次点击已选节点则取消高亮
selectedNode = null;
neighbors.clear();
} else {
selectedNode = node;
// 使用 Graphology 极速获取一阶邻居集合
neighbors = new Set(graph.neighbors(node));
neighbors.add(node);
}
// 刷新渲染状态(自动调用 Reducer)
renderer.refresh();
});
// 点击画布空白处,清除选中高亮
renderer.on("clickStage", () => {
if (selectedNode !== null) {
selectedNode = null;
neighbors.clear();
renderer.refresh();
}
});
// 动态节点着色器
renderer.setSetting("nodeReducer", (node, data) => {
const res = { ...data };
if (selectedNode) {
if (neighbors.has(node)) {
// 目标节点与邻居节点:高亮展示,突出强调色
res.highlighted = true;
if (node === selectedNode) {
res.color = "#AD8B58"; // 选中节点赋予高亮金色
res.size = data.size * 1.3;
}
} else {
// 无关节点:降低透明度并去除文字标签
res.color = "#E5DFD3";
res.label = "";
}
}
return res;
});
// 动态边着色器
renderer.setSetting("edgeReducer", (edge, data) => {
const res = { ...data };
if (selectedNode) {
// 仅保留与选中节点关联的边
if (graph.hasExtremity(edge, selectedNode)) {
res.color = "#AD8B58";
res.size = 2.5;
} else {
res.hidden = true; // 其余连线完全隐藏
}
}
return res;
});
四、实现平滑拖拽:坐标系映射与镜头锁定
在知识图谱交互中,拖拽调整节点布局是最高频的操作之一。但许多开发者在初次实现拖拽时,会发现“节点拖动速度飞快”、“拖动节点时整个画布背景跟着跑”,或者“一旦画布缩放过,节点就被甩到不知道哪里去了”。
1. 两个必须理解的关键点
- 必须转换坐标:鼠标事件给出的
clientX / clientY或e.x / e.y是视口像素坐标,范围是[0, 屏幕宽/高];而节点在 Graphology 中的坐标是逻辑世界的浮点数。如果不经过renderer.viewportToGraph(pos)转换直接赋值,坐标系错乱会导致节点瞬间跳变。 - 必须禁用相机控制:在默认情况下,按住鼠标左键在画布上移动会触发 Sigma 的相机全图平移(Pan)。当鼠标按在节点上拖动时,若不先调用
renderer.getCamera().disable(),就会同时触发“节点移动”与“画布视口移动”两个相加的位移向量,造成极其严重的抖动和漂移。
2. 工业级拖拽状态机实现
我们利用 Sigma 的 downNode 事件配合原生容器或 renderer.getMouseCaptor() 监听,构建出严谨的三阶段状态控制:
// 状态机变量
let draggedNode = null;
let isDragging = false;
// 阶段 1:按下节点,进入拖拽准备
renderer.on("downNode", (e) => {
isDragging = true;
draggedNode = e.node;
// 关键:立刻关闭相机导航,防止拖拽节点时触发画布整体平移
renderer.getCamera().disable();
});
// 阶段 2:鼠标移动中,实时换算并更新节点坐标
// 使用 Sigma 自带的 MouseCaptor 监听移动,它会自动规整移动事件
const mouseCaptor = renderer.getMouseCaptor();
mouseCaptor.on("mousemovebody", (e) => {
if (!isDragging || !draggedNode) return;
// 调用内置 API:将屏幕像素坐标准确映射为图谱逻辑坐标
// e 中包含了经过 captor 规整后的视口坐标 x 和 y
const graphCoord = renderer.viewportToGraph({ x: e.x, y: e.y });
// 实时写入 Graphology 模型,Sigma 会在下一帧自动重绘
graph.setNodeAttribute(draggedNode, "x", graphCoord.x);
graph.setNodeAttribute(draggedNode, "y", graphCoord.y);
// 阻止 Sigma 默认事件与浏览器默认行为
e.preventSigmaDefault();
if (e.original) {
e.original.preventDefault();
e.original.stopPropagation();
}
});
// 阶段 3:鼠标松开,释放拖拽状态
mouseCaptor.on("mouseup", () => {
if (isDragging) {
isDragging = false;
draggedNode = null;
// 关键:重新激活相机导航功能
renderer.getCamera().enable();
}
});
mousemovebody 回调中,调用 e.preventSigmaDefault() 能有效避免拖拽产生的外溢框选或背景拖拽干扰。同时,在松开鼠标时务必记得恢复相机的 enable(),否则用户将无法继续用鼠标拖动画布或缩放视图。
五、实现悬停提示:HTML Tooltip 与 WebGL 视口坐标动态对准
WebGL 擅长绘制海量点线,但在排版多行富文本、渲染复杂图标以及支持文本复制时成本极高。因此,专业的可视化应用无一例外采用“下层 WebGL 负责圆点渲染 + 上层 HTML DOM 负责信息浮窗”的双层设计。
1. 正向坐标投影:graphToViewport
悬停的核心要求是:当光标进入节点范围时,浮窗需要恰好跟随在节点旁边;无论镜头怎样放大、缩小或者平移,浮窗的位置都必须精准对准节点在屏幕上的像素坐标。
这正是 renderer.graphToViewport() 的用武之地。它接收图谱逻辑坐标 { x, y },结合当前摄像机的缩放比(ratio)和镜头中心偏移,实时计算出当前节点在宿主容器 #container 中的准确屏幕像素坐标:
const tooltip = document.getElementById("tooltip");
// 鼠标滑入节点
renderer.on("enterNode", ({ node }) => {
// 1. 获取该节点的业务属性
const attrs = graph.getNodeAttributes(node);
// 2. 将图谱坐标换算为视口像素坐标
const screenPos = renderer.graphToViewport({ x: attrs.x, y: attrs.y });
// 3. 装配提示内容(支持丰富的 HTML 格式)
tooltip.innerHTML = `
<div style="font-weight: 700; margin-bottom: 4px; color: #AD8B58;">${attrs.label}</div>
<div style="color: #FAF7F1; font-size: 11px;">节点 ID: ${node}</div>
<div style="color: #FAF7F1; font-size: 11px;">所属分类: ${attrs.category}</div>
`;
// 4. 定位并显现浮层
tooltip.style.left = `${screenPos.x}px`;
tooltip.style.top = `${screenPos.y}px`;
tooltip.style.display = "block";
});
// 鼠标移出节点
renderer.on("leaveNode", () => {
tooltip.style.display = "none";
});
2. 处理镜头运动中的浮层脱节问题
在实际项目中,用户经常在鼠标悬停在节点上时直接滚动滚轮缩放,或者拖动画布。此时由于鼠标并没有发生 leaveNode 事件,原先固定在像素坐标处的 Tooltip 就会与发生位移的节点“分道扬镳”。
为了解决这一体验瑕疵,我们需要监听相机的 updated 事件:一旦视口发生平移或缩放,立刻隐藏浮窗;或者根据当前悬停节点重新计算一次屏幕位置:
// 当镜头发生位移或缩放时,即时隐藏悬停浮窗,防止位移脱节
renderer.getCamera().on("updated", () => {
if (tooltip.style.display !== "none") {
tooltip.style.display = "none";
}
});
六、性能优化与生产级避坑指南
将上述三种交互集成到实际业务(如几千至数万个节点的大型图谱)时,有几个关键维度的坑需要提前规避:
1. 为什么不能在 mousemove 中执行全量布局或复杂 DOM 更新?
拖拽时,mousemovebody 会在每次鼠标微小晃动时频繁触发(通常每秒达 60~120 次)。如果在此阶段执行 graph.forEachNode(...) 或者进行复杂的 DOM 查询,浏览器的事件主线程会被瞬间占满,导致原本流畅的 WebGL 渲染出现严重掉帧。
优化策略:拖拽回调中仅调用单节点的 graph.setNodeAttribute()。Sigma 内部已经针对单个节点的位置变更做了 GPU 顶点缓冲区的局部更新优化,耗时仅需不到 0.5 毫秒。
2. 单页应用(SPA)中的事件解绑与销毁
在 React、Vue 等现代单页框架中,图谱往往封装在某个组件中。组件在被卸载(Unmount)时,如果仅仅移除了 DOM 节点,而未妥善注销 Sigma 实例与全局鼠标事件,会导致内存泄漏以及隐蔽的性能损耗。
标准清理做法是在组件销毁生命周期中执行:
// 组件卸载时的正确释放姿势
function cleanup() {
if (renderer) {
// 销毁渲染器实例,释放 WebGL 上下文及所有内部监听
renderer.kill();
}
if (graph) {
// 清空图数据拓扑
graph.clear();
}
}
七、完整集成代码范例
以下为整合了点击聚焦高亮、平滑拖拽移动、悬停 HTML Tooltip 及镜头联动的完整可运行范例。你可以将其直接保存为本地 HTML 文件并在浏览器中直接打开验证:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Sigma.js 完整交互综合范例</title>
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden;
font-family: -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
}
#graph-container {
width: 100%; height: 100%; background: #FAF7F1;
}
#tooltip {
position: absolute; display: none; padding: 10px 14px;
background: #394957; color: #FAF7F1; font-size: 12px;
border-radius: 6px; pointer-events: none;
box-shadow: 0 4px 16px rgba(0,0,0,0.18); z-index: 99;
transform: translate(12px, -50%);
}
</style>
<script src="https://cdn.jsdelivr.net/npm/graphology@0.25.4/dist/graphology.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/sigma@2.4.0/dist/sigma.min.js"></script>
</head>
<body>
<div id="graph-container"></div>
<div id="tooltip"></div>
<script>
const { Graph } = window.graphology;
const Sigma = window.Sigma;
// 1. 构造拓扑图
const graph = new Graph();
const count = 12;
for (let i = 0; i < count; i++) {
const rad = (i * 2 * Math.PI) / count;
graph.addNode(`n-${i}`, {
x: Math.cos(rad) * 8,
y: Math.sin(rad) * 8,
size: 15,
label: `节点 ${i + 1}`,
color: "#394957",
category: i % 2 === 0 ? "数据库集群" : "计算服务"
});
}
for (let i = 0; i < count; i++) {
graph.addEdge(`n-${i}`, `n-${(i + 1) % count}`, { size: 2, color: "#E5DFD3" });
graph.addEdge(`n-${i}`, `n-${(i + 4) % count}`, { size: 1.5, color: "#E5DFD3" });
}
// 2. 初始化渲染器
const container = document.getElementById("graph-container");
const tooltip = document.getElementById("tooltip");
const renderer = new Sigma(graph, container);
// 3. 交互状态
let selectedNode = null;
let neighbors = new Set();
let draggedNode = null;
let isDragging = false;
// --- 交互 A: 节点点击与 Reducer 着色 ---
renderer.on("clickNode", ({ node }) => {
if (selectedNode === node) {
selectedNode = null;
neighbors.clear();
} else {
selectedNode = node;
neighbors = new Set(graph.neighbors(node));
neighbors.add(node);
// 镜头居中聚焦
const pos = renderer.getNodeDisplayData(node);
renderer.getCamera().animate({ x: pos.x, y: pos.y, ratio: 0.7 }, { duration: 400 });
}
renderer.refresh();
});
renderer.on("clickStage", () => {
if (selectedNode) {
selectedNode = null;
neighbors.clear();
renderer.refresh();
}
});
renderer.setSetting("nodeReducer", (node, data) => {
const res = { ...data };
if (selectedNode) {
if (neighbors.has(node)) {
res.color = node === selectedNode ? "#AD8B58" : "#394957";
res.highlighted = true;
} else {
res.color = "#E8E2D5";
res.label = "";
}
}
return res;
});
// --- 交互 B: 节点平滑拖拽 ---
renderer.on("downNode", (e) => {
isDragging = true;
draggedNode = e.node;
renderer.getCamera().disable(); // 必须:锁定相机视口
});
const mouseCaptor = renderer.getMouseCaptor();
mouseCaptor.on("mousemovebody", (e) => {
if (!isDragging || !draggedNode) return;
const graphPos = renderer.viewportToGraph({ x: e.x, y: e.y });
graph.setNodeAttribute(draggedNode, "x", graphPos.x);
graph.setNodeAttribute(draggedNode, "y", graphPos.y);
e.preventSigmaDefault();
});
mouseCaptor.on("mouseup", () => {
if (isDragging) {
isDragging = false;
draggedNode = null;
renderer.getCamera().enable(); // 必须:恢复相机视口
}
});
// --- 交互 C: 悬停提示浮层 ---
renderer.on("enterNode", ({ node }) => {
const attrs = graph.getNodeAttributes(node);
const pos = renderer.graphToViewport({ x: attrs.x, y: attrs.y });
tooltip.innerHTML = `
<div style="font-weight:bold;color:#AD8B58;margin-bottom:2px;">${attrs.label}</div>
<div>类型: ${attrs.category}</div>
<div>关联邻居: ${graph.degree(node)} 个</div>
`;
tooltip.style.left = `${pos.x}px`;
tooltip.style.top = `${pos.y}px`;
tooltip.style.display = "block";
});
renderer.on("leaveNode", () => {
tooltip.style.display = "none";
});
renderer.getCamera().on("updated", () => {
if (tooltip.style.display !== "none") tooltip.style.display = "none";
});
</script>
</body>
</html>
八、总结
实现 sigma.js 的交互并不复杂,关键在于顺应其架构理念,理解数据、镜头与视口之间的协同规则:
- 点击:利用
clickNode捕获事件,结合camera.animate()聚焦视野,依靠nodeReducer完成动态样式过滤,坚决避免粗暴修改全图原始数据。 - 拖拽:严格遵循“在
downNode中关闭相机 → 在移动中通过viewportToGraph()映射坐标 → 在mouseup中复原相机”的状态机模型,彻底消灭抖动与漂移。 - 悬停提示:将 WebGL 的点坐标通过
graphToViewport()动态投影至屏幕像素,交由独立的 HTML DOM 浮层负责富文本排版,并与camera.updated保持同步。
掌握了这三套标准交互机制后,无论是开发数百节点的组织架构图谱,还是数十万节点的复杂风控与网络安全态势感知大屏,你都能在保证 60 帧丝滑渲染的前提下,为用户交付细腻、直观、高响应度的图谱探索体验。
Matter.js 与 PixiJS/Three.js 集成:分离物理计算与渲染
2026-11-05 13:43:48
主流JS弹幕库选型指南:Danmaku.js等库的功能与体积对比
2026-11-05 09:28:19
JavaScript点击事件监听与交互效果实例
2026-11-05 09:10:19
自定义JS颜色选择器UI样式与交互细节
2026-11-05 09:09:06
海量数据下 JS 统计图卡顿怎么办?性能优化实战技巧
2026-11-04 21:46:23








