Custom Force Layout Color Clustering
This example seeds a node-only graph with random positions and random colors, mounts a custom `RGLayouts.ForceLayout` subclass, and makes same-color nodes cluster together on a dark canvas. A floating helper window lets users retune the force parameters, switch canvas interaction modes, and export the current clustered view.
Custom Force Layout for Color-Based Node Clustering
What This Example Builds
This example builds a full-screen force-layout playground where many circular nodes drift on a dark canvas and gradually regroup into color-based clusters. The graph starts as an edge-free field of nodes with random positions and random red, yellow, or blue fills, then a custom force solver pulls same-color nodes toward one another.
Users can tune the motion with two sliders, recolor every node with one button, use the built-in toolbar to control the running layout, and open a floating settings panel for canvas-mode changes and image export. The main point is not domain data. It is a focused demonstration of how to replace part of relation-graph’s default force behavior with attribute-driven attraction.
How the Data Is Organized
The demo declares one large inline rawNodes array and wraps it in an RGJsonData object with rootId: 'a', nodes, and an empty lines array. Before anything is inserted into the graph, each node is mutated in place with a random x, random y, and a random color chosen from red, yellow, and blue.
That preprocessing step matters because the graph is initialized in fixed layout first, so those seeded coordinates become the visible starting state before the custom layouter takes over. In a real application, the same structure could represent users grouped by segment, assets grouped by status, alerts grouped by severity, or any other dataset where clustering should come from node attributes rather than explicit edges.
How relation-graph Is Used
index.tsx wraps the page in RGProvider, and MyGraph.tsx uses RGHooks.useGraphInstance() as the main control surface. Instead of calling setJsonData(), the example inserts the prepared node-only dataset with addNodes() and addLines(), builds a MyForceLayout instance, and mounts it with setLayoutor(myLayout, true, true). That attachment is important because it lets the graph instance and the built-in toolbar control the custom layouter the same way they would control a built-in force layout.
The custom layouter extends RGLayouts.ForceLayout. In resetCalcNodes() it copies each node’s color into the calculation state as myColor. In calcNodesPosition() it still calls addGravityByNode() for pairwise repulsion, but it also calls addElasticByLine() when two nodes share the same color. That is the core implementation detail: attraction is derived from node metadata instead of visible graph lines.
The graph options are tuned to make the clustering easy to read. Nodes are circular 60x60 elements with no border, the default line shape is straight even though no lines are rendered initially, and the built-in toolbar is placed horizontally at the bottom-right. Styling is completed through SCSS overrides that set a dark canvas background and force node labels to white. There are no custom node, line, canvas, or viewport slots in this example.
The shared DraggableWindow component adds the workspace utilities. It exposes a draggable description panel, a settings overlay backed by RGHooks.useGraphStore(), runtime changes through graphInstance.setOptions(), and image export through prepareForImageGeneration(), domToImageByModernScreenshot(), and restoreAfterImageGeneration().
Key Interactions
- Moving the
Node Repulsion Coefficientslider updates React state, rewrites the custom layouter options, and restarts auto layout. - Moving the
Line Elastic Coefficientslider changes the same runtime options, but in this scene it influences the custom same-color attraction rule rather than visible edge springs. - Clicking
Randomly Change Colorsrewrites each node’s color withupdateNode(), pauses briefly so the color change is visible, and then reruns the layout so new clusters form. - The floating helper window can be dragged, minimized, and switched into a settings panel.
- The settings panel changes wheel behavior, changes canvas drag behavior, and downloads an image of the current graph.
- The built-in toolbar remains active because the custom layouter is mounted on the graph instance, so users can stop and restart the running force simulation from the graph UI.
Key Code Fragments
This snippet shows that the graph starts in fixed layout with circular nodes and a bottom-right toolbar before the custom solver is attached.
const graphOptions: RGOptions = {
debug: true,
defaultLineColor: 'rgba(255, 255, 255, 0.6)',
defaultNodeColor: 'rgba(255, 255, 255, 0.6)',
defaultNodeBorderWidth: 0,
defaultNodeShape: RGNodeShape.circle,
defaultNodeWidth: 60,
defaultNodeHeight: 60,
toolBarDirection: 'h',
toolBarPositionH: 'right',
toolBarPositionV: 'bottom',
layout: {
layoutName: 'fixed'
}
};
This fragment proves that the dataset is preprocessed before insertion by seeding random coordinates and random group colors on a node-only RGJsonData payload.
const data: RGJsonData = {
rootId: 'a',
nodes: rawNodes,
lines: []
};
data.nodes.forEach(node => {
node.x = Math.random() * 300;
node.y = Math.random() * 300;
node.color = ['red', 'yellow', 'blue'][Math.floor(Math.random() * 3)]
});
graphInstance.addNodes(data.nodes);
graphInstance.addLines(data.lines);
This block is the custom layout rule: normal node repulsion still runs, but same-color pairs also receive an extra elastic pull.
if (i !== j) {
const __node2 = this.forCalcNodes[j];
if (__node2.dragging) continue;
this.addGravityByNode(__node1, __node2);
if (__node1.myColor === __node2.myColor) {
this.addElasticByLine(
__node1,
__node2,
1 // Elasticity coefficient
);
}
}
This handler shows how the sliders are wired into the live layouter without rebuilding the graph data.
const updateLayoutOptions = async () => {
graphInstance.stopAutoLayout();
const forceLayout = graphInstance.layoutor as MyForceLayout;
if (forceLayout) {
forceLayout.updateOptions({
force_node_repulsion,
force_line_elastic
});
forceLayout.start();
}
graphInstance.startAutoLayout();
};
This recolor flow is what turns a simple button into a visible regrouping interaction.
graphInstance.getNodes().forEach(node => {
const newColor = ['red', 'yellow', 'blue'][Math.floor(Math.random() * 3)];
graphInstance.updateNode(node, {
color: newColor
});
});
await graphInstance.sleep(600);
await updateLayoutOptions();
This shared helper-panel code shows that the example also exposes runtime canvas settings and image export through relation-graph hooks and instance APIs.
const { options } = RGHooks.useGraphStore();
const dragMode = options.dragEventAction;
const wheelMode = options.wheelEventAction;
const downloadImage = async () => {
const canvasDom = await graphInstance.prepareForImageGeneration();
let graphBackgroundColor = graphInstance.getOptions().backgroundColor;
if (!graphBackgroundColor || graphBackgroundColor === 'transparent') {
graphBackgroundColor = '#ffffff';
}
// ...
await graphInstance.restoreAfterImageGeneration();
};
What Makes This Example Distinct
According to the prepared comparison data, this example is distinctive because it subclasses RGLayouts.ForceLayout to create clusters from node attributes even though the initial dataset has no lines. That matters because nearby force examples are different in what drives the motion. customer-layout-force-circular also installs a custom force layouter, but it keeps explicit links, ring constraints, and anchored nodes. This example instead focuses on free-form clustering in an open field.
The comparison data also shows that the runtime interaction mix is unusual: two live force sliders are paired with a Randomly Change Colors action that deliberately pauses before restarting layout so users can first see the attribute mutation and then watch the clusters reform. That makes the example a smaller and more controlled customization reference than force-classifier-pro, which adds live node streaming, group-dimension switching, and heavier overlay UI.
Its rare feature combination is also important. The example combines an edge-free inline dataset, color-coded circular nodes, a dark canvas, a shared floating workspace shell, and a custom layouter that can be retuned and retriggered on the same graph instance. The floating helper window and settings overlay are not unique by themselves, but in this demo they support a force-solver experiment rather than a generic viewer.
Where Else This Pattern Applies
This pattern transfers well to clustering views where relationships are inferred from attributes instead of stored as explicit edges. Examples include customer or user cohorts grouped by segment, infrastructure assets grouped by state, alerts grouped by severity, or document collections grouped by topic labels.
It also applies when teams need a custom force rule but still want to keep relation-graph’s runtime controls, toolbar behavior, and graph-instance APIs. The demo uses node color as the grouping signal, but the same approach can be extended to department, priority, ownership, risk tier, or any other categorical field that should influence motion on the canvas.