Skip to content

Commit 7067101

Browse files
committed
feat: group spatial clustering with box clouds, cluster force, and strength slider
- Add custom cluster force (centroid-pull) registered on first engine tick - Change cloud geometry from SphereGeometry to BoxGeometry with EdgesGeometry wireframe - Add clusterStrength config (default 0.3) with slider in Settings > Grouping - Fix label aspect ratio (0.1875 matching 512x96 canvas) - Extract createClusterForce to lib/cluster-force.ts with 5 unit tests - Add e2e test for cluster strength slider
1 parent efc6e3d commit 7067101

7 files changed

Lines changed: 488 additions & 6 deletions

File tree

components/graph-canvas.tsx

Lines changed: 31 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ import {
3333
getModuleColor,
3434
} from "@/lib/views";
3535
import { cloudGroup } from "@/src/cloud-group";
36+
import { createClusterForce } from "@/lib/cluster-force";
3637

3738
// eslint-disable-next-line @typescript-eslint/no-explicit-any
3839
const ForceGraph3D = dynamic(() => import("react-force-graph-3d") as any, {
@@ -155,6 +156,17 @@ export function GraphCanvas({
155156
fg.d3ReheatSimulation();
156157
}, [config.charge, config.distance]);
157158

159+
// Cluster force — update strength when slider changes (registration happens in handleEngineTick)
160+
useEffect(() => {
161+
const fg = fgRef.current;
162+
if (!fg) return;
163+
const cluster = fg.d3Force("cluster");
164+
if (cluster && typeof cluster.strength === "function") {
165+
cluster.strength(config.clusterStrength);
166+
fg.d3ReheatSimulation();
167+
}
168+
}, [config.clusterStrength]);
169+
158170
// Module clouds — stable tick handler using refs
159171
const clearClouds = useCallback((fg: ForceGraph3DInstance) => {
160172
if (cloudsRef.current.size === 0) return;
@@ -181,6 +193,20 @@ export function GraphCanvas({
181193
if (!fg) return;
182194
const cfg = configRef.current;
183195

196+
// Register cluster force on first tick (fgRef is guaranteed live here)
197+
if (!fg.d3Force("cluster")) {
198+
const cluster = createClusterForce(
199+
(node) => {
200+
const mod = (node.module as string | undefined)?.startsWith(".worktrees/")
201+
? undefined
202+
: (node.module as string) || undefined;
203+
return mod ? cloudGroup(mod) : undefined;
204+
},
205+
cfg.clusterStrength,
206+
);
207+
fg.d3Force("cluster", cluster);
208+
}
209+
184210
if (!cfg.showModuleBoxes) {
185211
if (cloudsRef.current.size > 0) clearClouds(fg);
186212
if (containerRef.current) containerRef.current.dataset.cloudCount = "0";
@@ -250,12 +276,12 @@ export function GraphCanvas({
250276
existing.label.position.set(cx, maxY + pad + 8, cz);
251277
existing.label.material.opacity = zoomFade;
252278
const labelScale = Math.max(rx, ry, rz) * 1.2;
253-
existing.label.scale.set(labelScale, labelScale * 0.15, 1);
279+
existing.label.scale.set(labelScale, labelScale * 0.1875, 1);
254280
} else {
255281
const color = getModuleColor(mod);
256282

257283
// Solid cloud with Phong shading — responds to scene lights
258-
const geo = new THREE.SphereGeometry(2, 24, 16);
284+
const geo = new THREE.BoxGeometry(2, 2, 2);
259285
const mat = new THREE.MeshPhongMaterial({
260286
color,
261287
transparent: true,
@@ -272,16 +298,15 @@ export function GraphCanvas({
272298
mesh.renderOrder = -1;
273299
scene.add(mesh);
274300

275-
// Wireframe overlay for 3D depth cues
276-
const wireGeo = new THREE.SphereGeometry(2, 12, 8);
301+
// Wireframe overlay for 3D depth cues — EdgesGeometry for clean 12-edge box outline
277302
const wireMat = new THREE.LineBasicMaterial({
278303
color,
279304
transparent: true,
280305
opacity: baseOpacity * 0.5,
281306
depthWrite: false,
282307
});
283308
const wireframe = new THREE.LineSegments(
284-
new THREE.WireframeGeometry(wireGeo),
309+
new THREE.EdgesGeometry(geo),
285310
wireMat,
286311
);
287312
wireframe.position.set(cx, cy, cz);
@@ -308,7 +333,7 @@ export function GraphCanvas({
308333
const spriteMat = new THREE.SpriteMaterial({ map: texture, transparent: true, depthWrite: false, opacity: zoomFade });
309334
const label = new THREE.Sprite(spriteMat);
310335
const labelScale = Math.max(rx, ry, rz) * 1.2;
311-
label.scale.set(labelScale, labelScale * 0.15, 1);
336+
label.scale.set(labelScale, labelScale * 0.1875, 1);
312337
label.position.set(cx, maxY + pad + 8, cz);
313338
scene.add(label);
314339

components/settings-panel.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ export function SettingsPanel({
7878
<span className="text-[#e0e0e0]">Module Clouds</span>
7979
</label>
8080
<Slider label="Cloud Opacity" value={config.boxOpacity} min={0.05} max={0.8} step={0.05} format={(v) => v.toFixed(2)} onChange={(v) => { onChange("boxOpacity", v); }} />
81+
<Slider label="Cluster Strength" value={config.clusterStrength} min={0} max={1} step={0.05} format={(v) => v.toFixed(2)} onChange={(v) => { onChange("clusterStrength", v); }} />
8182

8283
<div className="text-[10px] text-[#2563eb] uppercase mt-4 mb-2 tracking-wider">Physics</div>
8384
<Slider label="Repulsion" value={config.charge} min={-200} max={-5} step={5} format={(v) => String(Math.round(v))} onChange={(v) => { onChange("charge", v); }} />

e2e/groups.spec.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,22 @@ test.describe("Group Selection", () => {
101101
expect(attrs).toEqual(expect.arrayContaining(["services", "models", "utils", "src", "types"]));
102102
});
103103

104+
test("cluster strength slider exists and is adjustable", async ({ page }) => {
105+
const slider = page.locator("input[type='range']").last();
106+
await expect(slider).toBeVisible();
107+
108+
// The settings panel should show "Cluster Strength" label
109+
await expect(page.getByText("Cluster Strength")).toBeVisible();
110+
111+
// Adjust the slider value
112+
await slider.fill("0.8");
113+
await expect(slider).toHaveValue("0.8");
114+
115+
// Set to 0 (off)
116+
await slider.fill("0");
117+
await expect(slider).toHaveValue("0");
118+
});
119+
104120
test("groups only visible when Module Clouds checkbox is checked", async ({ page }) => {
105121
// Groups should be visible initially (checkbox is checked by default)
106122
await expect(page.getByText("Groups", { exact: false })).toBeVisible();

lib/cluster-force.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
export interface ClusterForce {
2+
(alpha: number): void;
3+
initialize: (nodes: Array<Record<string, unknown>>) => void;
4+
strength: (s: number) => ClusterForce;
5+
}
6+
7+
export function createClusterForce(
8+
getClusterId: (node: Record<string, unknown>) => string | undefined,
9+
strength: number,
10+
): ClusterForce {
11+
let nodes: Array<Record<string, unknown>> = [];
12+
let currentStrength = strength;
13+
14+
function force(alpha: number): void {
15+
if (currentStrength === 0) return;
16+
const centroids = new Map<string, { x: number; y: number; z: number; count: number }>();
17+
for (const node of nodes) {
18+
if (node.x === undefined) continue;
19+
const id = getClusterId(node);
20+
if (!id) continue;
21+
const c = centroids.get(id) ?? { x: 0, y: 0, z: 0, count: 0 };
22+
c.x += node.x as number;
23+
c.y += node.y as number;
24+
c.z += node.z as number;
25+
c.count++;
26+
centroids.set(id, c);
27+
}
28+
for (const c of centroids.values()) {
29+
c.x /= c.count;
30+
c.y /= c.count;
31+
c.z /= c.count;
32+
}
33+
const k = currentStrength * alpha;
34+
for (const node of nodes) {
35+
if (node.x === undefined) continue;
36+
const id = getClusterId(node);
37+
if (!id) continue;
38+
const c = centroids.get(id);
39+
if (!c || c.count < 2) continue;
40+
const dx = c.x - (node.x as number);
41+
const dy = c.y - (node.y as number);
42+
const dz = c.z - (node.z as number);
43+
if (dx * dx + dy * dy + dz * dz < 25) continue;
44+
node.vx = ((node.vx as number) || 0) + dx * k;
45+
node.vy = ((node.vy as number) || 0) + dy * k;
46+
node.vz = ((node.vz as number) || 0) + dz * k;
47+
}
48+
}
49+
50+
force.initialize = function (n: Array<Record<string, unknown>>): void {
51+
nodes = n;
52+
};
53+
54+
force.strength = function (s: number): ClusterForce {
55+
currentStrength = s;
56+
return force;
57+
};
58+
59+
return force;
60+
}

lib/types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -127,6 +127,7 @@ export interface GraphConfig {
127127
distance: number;
128128
showModuleBoxes: boolean;
129129
boxOpacity: number;
130+
clusterStrength: number;
130131
}
131132

132133
export const DEFAULT_CONFIG: GraphConfig = {
@@ -140,6 +141,7 @@ export const DEFAULT_CONFIG: GraphConfig = {
140141
distance: 120,
141142
showModuleBoxes: true,
142143
boxOpacity: 0.4,
144+
clusterStrength: 0.3,
143145
};
144146

145147
export interface RenderNode {

0 commit comments

Comments
 (0)