sigma.js 怎么实现节点点击、拖拽与悬停提示交互

作者:白露时间:2026-11-05 15:03:27浏览:174

深入解析在 sigma.js 中如何基于 Graphology 实现高性能的图谱节点点击高亮、平滑拖拽移动以及 HTML 浮动悬停提示(Tooltip)交互,涵盖事件监听、坐标转换与镜头状态控制的核心代码与实践技巧。

sigma.js 怎么实现节点点击、拖拽与悬停提示交互
技术解析 阅读时长:约 10 分钟 技术栈:Sigma.js v2/v3 · Graphology · WebGL
在现代知识图谱与复杂网络可视化开发中,sigma.js 凭借基于 WebGL 的高性能渲染能力,能够轻松承载数万级别节点与边的高频渲染。然而,许多初学者在实现常规交互时经常感到困惑:为什么鼠标拖拽时节点会产生莫名漂移?为什么点击事件无法直接修改节点的 CSS?悬停提示文本又该如何精确对齐 WebGL 场景中的圆点?本文将系统梳理 Sigma.js 的事件体系与坐标映射模型,通过清晰的步骤与完整示例,带你一次性掌握节点点击、平滑拖拽与浮动 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 自动重绘”的过程。

sigma.js 怎么实现节点点击、拖拽与悬停提示交互
图 1:sigma.js 交互事件捕获、坐标转换与渲染层流转模型

如图 1 所示,三种核心交互对应着不同的流转通道:

  1. 节点点击(Click):通过监听 clickNode 事件,获取当前节点 ID,既可以通过相机的 animate() 方法平滑聚焦,也可以结合 nodeReducer 实现一阶邻居的高亮筛选。
  2. 节点拖拽(Drag):拖拽需要处理屏幕像素坐标系到图谱世界坐标系的逆向换算。必须通过 renderer.viewportToGraph() 将鼠标指针位置转换为节点在拓扑中的新 x, y,并在拖拽过程中锁定相机平移。
  3. 悬停提示(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 是必填字段。它们定义了节点在拓扑虚拟空间中的分布位置,无需与屏幕分辨率画素直接对应。
sigma.js 怎么实现节点点击、拖拽与悬停提示交互
图 2:sigma.js 交互接入核心四步流水线

三、实现节点点击交互:邻居高亮与镜头平滑对焦

点击节点时,用户通常期望看到两项视觉反馈:一是该节点平滑移动到屏幕视觉正中心(视线聚焦);二是突出显示该节点及其直接相连的一阶邻居,同时弱化背景中无关的节点和连线。

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;
});

四、实现平滑拖拽:坐标系映射与镜头锁定

在知识图谱交互中,拖拽调整节点布局是最高频的操作之一。但许多开发者在初次实现拖拽时,会发现“节点拖动速度飞快”、“拖动节点时整个画布背景跟着跑”,或者“一旦画布缩放过,节点就被甩到不知道哪里去了”。

sigma.js 怎么实现节点点击、拖拽与悬停提示交互
图 3:拖拽状态机模型与双层坐标空间映射原理

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";
  }
});

六、性能优化与生产级避坑指南

将上述三种交互集成到实际业务(如几千至数万个节点的大型图谱)时,有几个关键维度的坑需要提前规避:

sigma.js 怎么实现节点点击、拖拽与悬停提示交互
图 4:交互实现核心策略与避坑排查对照总结

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 帧丝滑渲染的前提下,为用户交付细腻、直观、高响应度的图谱探索体验。

作者最新文章
相关文章
更多