JavaScript is required

分类器

这个示例生成一组模拟用户节点,并在分组字段切换时通过自定义力布局重新聚类。它演示了命令式图加载、基于插槽的节点徽章、悬浮图例,以及用于交互模式切换和图片导出的共享画布工具。

无关系线的可按属性切换的力导向聚类

这个示例构建了什么

这个示例渲染了一个不含边的场景,其中包含 100 个合成用户节点,并允许用户一次按一个属性对这片节点区域重新分组。界面中的可见控件可以在 region、major、grade 和 gender 之间切换当前分组方式,每次切换后,力导向布局都会重新计算聚类结果。每个节点都会渲染为一个紧凑的人物徽章,包含姓名、专业首字母、地区图标、年级颜色以及基于性别的形状,同时一个悬浮图例会说明这些视觉编码的含义。

这个示例最重要的思想是:图本身更像一个分类器试验场,而不是关系图。这里没有可见的连线。布局本身就是编码方式,而被选中的属性决定了哪些节点会相互吸引。

数据是如何组织的

这里的数据是一个在运行时生成的扁平节点列表,而不是从 JSON 加载的预定义图数据集。initializeGraph() 会创建 100 条随机用户记录,为它们分配随机坐标,并通过 addNodes()addLines() 将它们推入图中,而不是使用 setJsonData()。其中 line 列表被有意保持为空。

每个节点都包含用于显示的文本,以及一个带有 userRegionuserRegionIconuserMajoruserGradeuserGendercurrentGroupValue 的元数据对象。前五个字段描述实体本身。currentGroupValue 是一个派生字段,它镜像当前所选的分组键,也是自定义力布局判断两个节点是否应该相互吸引时唯一需要的属性。

在自定义布局运行之前,示例还会把元数据映射到可视属性上:grade 会成为 node.color,gender 会成为 node.nodeShape,初始分组值则会复制到 currentGroupValue 中。在真实应用中,这种结构同样可以表示员工、客户、学生、设备或案例,并且可以按不同的分类维度重新分组,而不需要更改底层实体列表。

relation-graph 是如何使用的

这个示例被包裹在 RGProvider 中,然后通过 RGHooks.useGraphInstance() 以命令式方式控制图实例。RelationGraph 在挂载时使用了力导向布局默认配置、透明白色的节点样式、直线配置,以及位于右下角的工具栏。尽管这个图没有有意义的边,标准图运行时仍然提供了视口、工具栏、插槽和布局生命周期。

主要的自定义点是布局替换。节点添加完成后,代码会实例化 MyForceLayout,它是 RGLayouts.ForceLayout 的一个子类,然后通过 setLayoutor(myLayout, true, true) 将其安装进去。这样既保留了 relation-graph 的生命周期,又可以替换为不同的力规则。图实例 API 随后负责初始摆放,以及后续通过 stopAutoLayout()startAutoLayout()getNodes()updateNodeData()moveToCenter()setZoom()sleep()zoomToFit() 完成重新分组。

插槽承担了大部分渲染工作。RGSlotOnNode 会把每个节点渲染成一个多属性徽章,而不是默认节点主体;RGSlotOnView 则将 DataLegendPanel 直接挂载到画布上,这样图例在图于下方移动时依然保持可见。共享的 DraggableWindow 组件提供了一个悬浮控制面板,支持拖动、最小化、画布设置和图片导出行为。本地 SCSS 则为激活态分组按钮提供样式,并覆盖了 relation-graph 外壳中的选中节点和连线样式。

关键交互

主要交互是悬浮窗口中的 group-by 按钮行。点击其中任意按钮后,会更新 currentGroupBy,重写每个节点的 currentGroupValue,停止当前这一轮力导向计算,然后重新启动自动布局,使节点云围绕新选中的类别重新成形。

悬浮窗口本身也是可交互的:可以拖到新的屏幕位置、最小化,或者切换到设置模式。设置面板可以把滚轮行为切换为 scroll、zoom 或 none,把画布拖拽行为切换为 selection、move 或 none,并提供图片导出操作。导出流程会在捕获画布 DOM 之前,先使用 relation-graph 的图片准备钩子。

图区域本身则被刻意保持得很简单。没有边编辑、没有可供查看的连线标签,也没有节点点击工作流。整个体验的核心是对同一组实体进行重新分组,并观察布局如何响应。

关键代码片段

这段初始化路径展示了该示例如何绕过 setJsonData(),并在活动图实例上安装一个自定义布局。

const data: RGJsonData = {
  rootId: 'a',
  nodes: randomUsers,
  lines: []
};
graphInstance.addNodes(data.nodes);
graphInstance.addLines(data.lines);
graphInstance.stopAutoLayout();

const myLayout = new MyForceLayout(
  { maxLayoutTimes: Number.MAX_SAFE_INTEGER, force_node_repulsion: 0.4, force_line_elastic: 0.1 },
  graphInstance.getOptions(),
  graphInstance
);
graphInstance.setLayoutor(myLayout, true, true);

每个生成的节点都会携带分类元数据和视觉默认值,供布局和插槽在后续复用。

const node: JsonNode = {
  id: 'u-' + graphInstance.generateNewNodeId(),
  text: userName,
  x: Math.random() * 300,
  y: Math.random() * 300,
};
node.data = {
  userRegion: region.code,
  userRegionIcon: region.icon,
  userMajor: majors[Math.floor(Math.random() * majors.length)],
  userGrade: randomGrade.grade,
  userGender
};
node.color = randomGrade.color;
node.nodeShape = userGender === 'Male' ? RGNodeShape.rect : RGNodeShape.circle;

重新分组的实现方式是重写元数据并重启布局,而不是完整重新加载数据。

graphInstance.stopAutoLayout();
graphInstance.getNodes().forEach(node => {
  graphInstance.updateNodeData(node, {
    currentGroupValue: node.data[currentGroupBy]
  });
});
setTimeout(async () => {
  graphInstance.startAutoLayout();
}, 200);

这个自定义布局只有在两个节点共享当前所选分组值时才会添加吸引力。

this.addGravityByNode(__node1, __node2);

if (__node1.myGroupBy === __node2.myGroupBy) {
  this.addElasticByLine(
    __node1,
    __node2,
    1
  );
}

节点插槽会把一条记录渲染为一个紧凑的徽章,包含文本、专业标记和地区图标。

<RGSlotOnNode>
  {({ node }) => (
    <div className="px-6 py-1 w-full h-full flex place-items-center justify-center text-xs">
      <div className="px-3 py-0.5 bg-gray-100 bg-opacity-30 rounded text-black text-sm">
        {node.text}
      </div>
      <div className="absolute left-[-3px] top-[-3px] h-4 w-4 border border-gray-900 bg-gray-600 rounded text-white text-sm flex place-items-center justify-center">
        {node.data.userMajor.substring(0, 1).toUpperCase()}
      </div>
      <div className="absolute right-[-3px] bottom-[-3px] text-xl">{node.data.userRegionIcon}</div>
    </div>
  )}
</RGSlotOnNode>

共享设置面板展示了这个示例如何切换画布行为并导出当前图像。

<SettingRow
  label="Wheel Event:"
  value={wheelMode}
  onChange={(newValue: string) => { graphInstance.setOptions({ wheelEventAction: newValue }); }}
/>
<SettingRow
  label="Canvas Drag Event:"
  value={dragMode}
  onChange={(newValue: string) => { graphInstance.setOptions({ dragEventAction: newValue }); }}
/>
<SimpleUIButton onClick={downloadImage}>Download Image</SimpleUIButton>

这个示例的独特之处

这个示例对应的预备 comparison 文件是空的,因此最稳妥的独特性判断只能来自 doc-context。该上下文将以下组合标记为少见,其中部分甚至非常少见:合成节点生成、通过 addNodes()addLines() 直接加载图而不是使用 setJsonData()、自定义 MyForceLayout extends RGLayouts.ForceLayout、通过 updateNodeData() 在运行时重新分组,以及在按所选属性聚类的同时保持视图完全无边。

views 标签进一步收窄了这个场景:该示例被明确归类为“attribute clustering playground”、“attribute-rich people badges”、“edge-free clustered node field”和“legend-guided light canvas”。这使它比一般的力导向布局示例更专门化。重点不在关系拓扑,而在于同一组实体如何围绕不同的分类维度自行重组。

最相近示例列表也提供了一个有用的定位方式。force-classifier-pro 是结构上最近的邻居,因为它共享这种自定义力导向布局试验场主题;而像 css-themenode-drag-handle 这样的示例,则更多是与可复用悬浮工具窗口和通用 RelationGraph 外壳存在重叠。因此,当目标是研究属性驱动的重新聚类逻辑本身时,这个示例是更干净的起点。

这种模式还适用于哪里

这种模式很适合任何需要让实体一次按一个选定维度重新分组的探索式视图:例如按办公地点、团队或级别分组的员工;按地区、专业或年级分组的学生;按市场、细分或层级分组的客户;或按站点、类型和状态分组的设备。

当显式边只会带来更多噪声而不是价值时,这种模式也很有用。一个轻量的力场加上一个可切换的分组键,就可以在保持数据模型仍是简单扁平节点列表的同时,展示分布、密度和聚类边界。