@@ -23,7 +23,7 @@ import { useGraph } from '../contexts/GraphContext';
2323import { useAuth } from '../contexts/AuthContext' ;
2424import { useNotifications } from '../contexts/NotificationContext' ;
2525import { useNavigate , useLocation } from 'react-router-dom' ;
26- import { GET_WORK_ITEMS , GET_EDGES , CREATE_EDGE , UPDATE_EDGE , DELETE_EDGE , CREATE_WORK_ITEM } from '../lib/queries' ;
26+ import { GET_WORK_ITEMS , GET_EDGES , CREATE_EDGE , UPDATE_EDGE , DELETE_EDGE , CREATE_WORK_ITEM , UPDATE_WORK_ITEM } from '../lib/queries' ;
2727import { validateGraphData , getValidationSummary , ValidationResult } from '../utils/graphDataValidation' ;
2828import { DEFAULT_NODE_CONFIG } from '../constants/workItemConstants' ;
2929
@@ -175,6 +175,12 @@ export function InteractiveGraphVisualization() {
175175 // Error handled by GraphQL error boundary
176176 }
177177 } ) ;
178+
179+ // Mutation for updating work item positions
180+ const [ updateWorkItemMutation ] = useMutation ( UPDATE_WORK_ITEM , {
181+ // Don't refetch - we want immediate UI updates without waiting for server
182+ errorPolicy : 'all'
183+ } ) ;
178184
179185 const [ nodeMenu , setNodeMenu ] = useState < NodeMenuState > ( { node : null , position : { x : 0 , y : 0 } , visible : false } ) ;
180186 const [ edgeMenu , setEdgeMenu ] = useState < EdgeMenuState > ( { edge : null , position : { x : 0 , y : 0 } , visible : false } ) ;
@@ -212,6 +218,24 @@ export function InteractiveGraphVisualization() {
212218 VERY_CLOSE : 2.0 // Full detail
213219 } ;
214220
221+ // Function to save node position to database
222+ const saveNodePosition = useCallback ( async ( nodeId : string , x : number , y : number ) => {
223+ try {
224+ await updateWorkItemMutation ( {
225+ variables : {
226+ where : { id : nodeId } ,
227+ update : {
228+ positionX : x ,
229+ positionY : y
230+ }
231+ }
232+ } ) ;
233+ console . log ( `Saved position for node ${ nodeId } : (${ x . toFixed ( 1 ) } , ${ y . toFixed ( 1 ) } )` ) ;
234+ } catch ( error ) {
235+ console . error ( 'Error saving node position:' , error ) ;
236+ }
237+ } , [ updateWorkItemMutation ] ) ;
238+
215239 // Function to get SVG path for priority icons
216240 const getPriorityIconSvgPath = ( priorityValue : number ) : string => {
217241 if ( priorityValue >= 0.8 ) return 'M12 2L2 7v10c0 5.55 3.84 10 9 11 1.16-.21 2.31-.54 3.42-1.01C16.1 26.46 18.05 25.24 20 23.5 21.95 21.76 23.84 19.54 24 17v-10L12 2z' ; // Flame
@@ -556,18 +580,38 @@ export function InteractiveGraphVisualization() {
556580 const validatedEdges = currentValidationResult . validEdges ;
557581
558582 const nodes = [
559- // Real nodes from database
560- ...validatedNodes . map ( item => ( {
561- ...item ,
562- x : item . positionX ,
563- y : item . positionY ,
564- priority : {
565- executive : item . priorityExec ,
566- individual : item . priorityIndiv ,
567- community : item . priorityComm ,
568- computed : item . priorityComp
583+ // Real nodes from database with smart initial positioning
584+ ...validatedNodes . map ( ( item , index ) => {
585+ // Check if this node has any connections
586+ const hasConnections = validatedEdges . some ( edge =>
587+ edge . source . id === item . id || edge . target . id === item . id
588+ ) ;
589+
590+ let x = item . positionX ;
591+ let y = item . positionY ;
592+
593+ // If node has never been positioned (0,0) and has no connections, place it on periphery
594+ if ( ( item . positionX === 0 && item . positionY === 0 ) && ! hasConnections ) {
595+ const angle = ( index / validatedNodes . length ) * 2 * Math . PI ;
596+ const radius = Math . min ( window . innerWidth , window . innerHeight ) * 0.4 ; // Place on outer ring
597+ const centerX = 0 ; // Start from center
598+ const centerY = 0 ;
599+ x = centerX + Math . cos ( angle ) * radius ;
600+ y = centerY + Math . sin ( angle ) * radius ;
569601 }
570- } ) )
602+
603+ return {
604+ ...item ,
605+ x,
606+ y,
607+ priority : {
608+ executive : item . priorityExec ,
609+ individual : item . priorityIndiv ,
610+ community : item . priorityComm ,
611+ computed : item . priorityComp
612+ }
613+ } ;
614+ } )
571615 ] ;
572616
573617 // Helper function to check if edge already exists
@@ -871,39 +915,56 @@ export function InteractiveGraphVisualization() {
871915 simulation
872916 . force ( 'link' , d3 . forceLink ( validatedEdges )
873917 . id ( ( d : any ) => d . id )
874- . distance ( 500 ) // Much larger distance for massive spread
875- . strength ( 0.05 ) // Weaker to allow more flexibility
876- )
877- . force ( 'charge' , d3 . forceManyBody ( )
918+ . distance ( ( d : any ) => {
919+ const minDistance = Math . min ( width , height ) * 0.4 ; // 40% of screen size for minimum
920+ const maxDistance = Math . min ( width , height ) * 0.6 ; // 60% of screen size for maximum
921+
922+ // Calculate current distance between nodes
923+ const sourceNode = d . source ;
924+ const targetNode = d . target ;
925+ const currentDistance = Math . sqrt (
926+ Math . pow ( targetNode . x - sourceNode . x , 2 ) +
927+ Math . pow ( targetNode . y - sourceNode . y , 2 )
928+ ) ;
929+
930+ // If current distance exceeds maximum, return maximum to create pulling force
931+ if ( currentDistance > maxDistance ) {
932+ return maxDistance ;
933+ }
934+
935+ // Otherwise use minimum as preferred distance
936+ return minDistance ;
937+ } )
878938 . strength ( ( d : any ) => {
879- // Much stronger repulsion for maximum spread
880- switch ( d . type ) {
881- case 'EPIC' :
882- return - 1200 ; // Extreme repulsion
883- case 'OUTCOME' :
884- return - 1000 ;
885- case 'MILESTONE' :
886- return - 900 ;
887- case 'FEATURE' :
888- return - 800 ;
889- case 'TASK' :
890- return - 700 ;
891- case 'BUG' :
892- return - 700 ;
893- case 'IDEA' :
894- return - 600 ;
895- default :
896- return - 800 ;
939+ // Calculate current distance between nodes
940+ const sourceNode = d . source ;
941+ const targetNode = d . target ;
942+ const currentDistance = Math . sqrt (
943+ Math . pow ( targetNode . x - sourceNode . x , 2 ) +
944+ Math . pow ( targetNode . y - sourceNode . y , 2 )
945+ ) ;
946+
947+ const maxDistance = Math . min ( width , height ) * 0.6 ;
948+
949+ // Stronger force when edge is too long to create pulling effect
950+ if ( currentDistance > maxDistance ) {
951+ return 0.8 ; // Strong pulling force
897952 }
953+
954+ // Normal strength otherwise
955+ return 0.3 ;
898956 } )
899- . distanceMax ( 1500 ) // Much larger max distance for wider influence
957+ )
958+ . force ( 'charge' , d3 . forceManyBody ( )
959+ . strength ( - 100 ) // Simple, consistent repulsion for all nodes
960+ . distanceMax ( 200 ) // Reasonable influence range
900961 )
901962 . force ( 'center' , d3 . forceCenter ( centerX , centerY ) . strength ( 0.01 ) ) // Minimal centering
902963 . force ( 'x' , d3 . forceX ( centerX ) . strength ( 0.002 ) ) // Extremely weak horizontal centering for maximum width
903964 . force ( 'y' , d3 . forceY ( centerY ) . strength ( 0.002 ) ) // Extremely weak vertical centering for maximum height
904- . force ( 'collision' , d3 . forceCollide ( 250 ) // Much larger collision radius for maximum spacing
905- . strength ( 0.95 ) // Very strong collision prevention
906- . iterations ( 5 ) // More iterations for better separation
965+ . force ( 'collision' , d3 . forceCollide ( 90 ) // Sufficient collision radius to prevent overlap
966+ . strength ( 0.7 ) // Moderate collision prevention
967+ . iterations ( 2 ) // Fewer iterations for stability
907968 )
908969 // Add hierarchical attraction forces (Epic->Milestone, Feature->Task, etc.)
909970 . force ( 'hierarchy' , d3 . forceLink ( )
@@ -1011,13 +1072,57 @@ export function InteractiveGraphVisualization() {
10111072 return ;
10121073 }
10131074
1075+ // Store initial position and connected nodes for cluster behavior
1076+ d . _dragStart = { x : d . x , y : d . y } ;
1077+ d . _connectedNodes = validatedEdges
1078+ . filter ( edge => edge . source . id === d . id || edge . target . id === d . id )
1079+ . map ( edge => {
1080+ const connectedNode = edge . source . id === d . id ? edge . target : edge . source ;
1081+ return {
1082+ node : connectedNode ,
1083+ wasFixed : connectedNode . fx !== null || connectedNode . fy !== null
1084+ } ;
1085+ } ) ;
1086+
10141087 // Normal drag behavior
10151088 if ( ! event . active ) simulation . alphaTarget ( 0.2 ) . restart ( ) ;
10161089 d . fx = d . x ;
10171090 d . fy = d . y ;
10181091 } )
10191092 . on ( 'drag' , ( event , d : any ) => {
1020- // Allow free dragging without bounds constraints
1093+ // Calculate drag distance from start
1094+ const dragDistance = Math . sqrt (
1095+ Math . pow ( event . x - d . _dragStart . x , 2 ) +
1096+ Math . pow ( event . y - d . _dragStart . y , 2 )
1097+ ) ;
1098+
1099+ // Threshold for switching from cluster movement to edge stretching
1100+ const stretchThreshold = 80 ; // pixels
1101+
1102+ if ( dragDistance < stretchThreshold ) {
1103+ // Cluster movement - move connected nodes together
1104+ const deltaX = event . x - d . x ;
1105+ const deltaY = event . y - d . y ;
1106+
1107+ d . _connectedNodes . forEach ( ( { node, wasFixed } ) => {
1108+ if ( ! wasFixed ) { // Only move if not already fixed by user previously
1109+ node . fx = ( node . fx || node . x ) + deltaX ;
1110+ node . fy = ( node . fy || node . y ) + deltaY ;
1111+ node . x = node . fx ;
1112+ node . y = node . fy ;
1113+ }
1114+ } ) ;
1115+ } else {
1116+ // Edge stretching - release connected nodes to move independently
1117+ d . _connectedNodes . forEach ( ( { node, wasFixed } ) => {
1118+ if ( ! wasFixed ) { // Only release if we were controlling it and it wasn't user-fixed
1119+ node . fx = null ;
1120+ node . fy = null ;
1121+ }
1122+ } ) ;
1123+ }
1124+
1125+ // Move the dragged node
10211126 d . fx = event . x ;
10221127 d . fy = event . y ;
10231128 d . x = d . fx ;
@@ -1054,14 +1159,20 @@ export function InteractiveGraphVisualization() {
10541159 return ;
10551160 }
10561161
1057- // Normal drag end behavior
1058- if ( ! event . active ) simulation . alphaTarget ( 0.05 ) ;
1059- // Keep position fixed for a short time to allow other nodes to settle
1162+ // Sticky drag behavior - node stays where user put it
1163+ if ( ! event . active ) simulation . alphaTarget ( 0.1 ) . restart ( ) ;
1164+
1165+ // Keep the node fixed at the dropped position
1166+ // Don't release fx/fy - let the node stay where the user put it
1167+ // The physics will adapt around the fixed position
1168+
1169+ // Save the new position to the database
1170+ saveNodePosition ( d . id , d . fx , d . fy ) ;
1171+
1172+ // Gradually reduce simulation energy to let other nodes settle
10601173 setTimeout ( ( ) => {
1061- d . fx = null ;
1062- d . fy = null ;
10631174 simulation . alphaTarget ( 0.02 ) ;
1064- } , 500 ) ;
1175+ } , 1000 ) ;
10651176 mousedownNodeRef . current = null ;
10661177 } ) ) ;
10671178
@@ -1903,8 +2014,11 @@ export function InteractiveGraphVisualization() {
19032014 . attr ( 'stroke-width' , scale >= LOD_THRESHOLDS . FAR ? 1 : Math . max ( 0.3 , scale * 0.8 ) ) ;
19042015 } ) ;
19052016
1906- // Properly restart simulation to ensure initial positioning works
1907- simulation . alpha ( 0.8 ) . restart ( ) ;
2017+ // Configure simulation for stability
2018+ simulation
2019+ . alpha ( 0.6 ) // Lower starting energy for stability
2020+ . alphaDecay ( 0.015 ) // Slower decay for smoother movement
2021+ . restart ( ) ;
19082022
19092023 // Add method to restart collision detection
19102024 ( simulation as any ) . restartCollisions = ( ) => {
0 commit comments