@@ -33,6 +33,7 @@ import {
3333 getModuleColor ,
3434} from "@/lib/views" ;
3535import { cloudGroup } from "@/src/cloud-group" ;
36+ import { createClusterForce } from "@/lib/cluster-force" ;
3637
3738// eslint-disable-next-line @typescript-eslint/no-explicit-any
3839const 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
0 commit comments