Skip to content

Commit 3ecbd44

Browse files
committed
Implement advanced node physics and persistent positioning
- Add persistent node positioning that saves drag positions to database - Implement smart initial positioning for unconnected nodes on periphery - Add cluster drag behavior with edge stretching threshold (80px) - Implement dynamic edge length constraints (min 40%, max 60% of screen) - Stronger repulsion for connected nodes to emphasize edge connections - Stabilize physics simulation with simplified force calculations - Remove glitchy peripheral force and complex charge calculations - Configure simulation for smoother movement and better stability - Automatic position saving on drag end with console logging - Unconnected nodes start distributed in circle around edge - Connected nodes use saved positions and stay in focus area
1 parent 8f8a1c3 commit 3ecbd44

1 file changed

Lines changed: 161 additions & 47 deletions

File tree

packages/web/src/components/InteractiveGraphVisualization.tsx

Lines changed: 161 additions & 47 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import { useGraph } from '../contexts/GraphContext';
2323
import { useAuth } from '../contexts/AuthContext';
2424
import { useNotifications } from '../contexts/NotificationContext';
2525
import { 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';
2727
import { validateGraphData, getValidationSummary, ValidationResult } from '../utils/graphDataValidation';
2828
import { 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

Comments
 (0)