Skip to content

Commit bbed27f

Browse files
committed
Add context menu for empty space clicks with zoom to fit functionality
- Replace direct node creation on empty space clicks with context menu - Add 'Create Node' option to create nodes at clicked position - Add 'Zoom to Fit' option that scales and centers view to show all nodes - Context menu supports both left-click and right-click - Exclusive dialog behavior closes other dialogs when context menu opens - Zoom to fit properly syncs with D3 zoom behavior state to prevent reversion - Context menu positioned with viewport boundary checking
1 parent d77dad2 commit bbed27f

1 file changed

Lines changed: 168 additions & 19 deletions

File tree

packages/web/src/components/InteractiveGraphVisualization.tsx

Lines changed: 168 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -187,6 +187,7 @@ export function InteractiveGraphVisualization() {
187187
const [createNodePosition, setCreateNodePosition] = useState<{ x: number; y: number; z: number } | undefined>(undefined);
188188
const [currentTransform, setCurrentTransform] = useState({ x: 0, y: 0, scale: 1 });
189189
const [editingEdge, setEditingEdge] = useState<{ edge: WorkItemEdge; position: { x: number; y: number } } | null>(null);
190+
const [contextMenuPosition, setContextMenuPosition] = useState<{ x: number; y: number; graphX: number; graphY: number } | null>(null);
190191

191192
// Level of detail thresholds
192193
const LOD_THRESHOLDS = {
@@ -540,15 +541,6 @@ export function InteractiveGraphVisualization() {
540541
const validatedNodes = currentValidationResult.validNodes;
541542
const validatedEdges = currentValidationResult.validEdges;
542543

543-
544-
// Helper function to check if edge already exists
545-
const edgeExists = (sourceId: string, targetId: string): boolean => {
546-
return validatedEdges.some(edge =>
547-
(edge.source === sourceId && edge.target === targetId) ||
548-
(edge.source === targetId && edge.target === sourceId) // Check both directions
549-
);
550-
};
551-
552544
const nodes = [
553545
// Real nodes from database
554546
...validatedNodes.map(item => ({
@@ -563,6 +555,14 @@ export function InteractiveGraphVisualization() {
563555
}
564556
}))
565557
];
558+
559+
// Helper function to check if edge already exists
560+
const edgeExists = (sourceId: string, targetId: string): boolean => {
561+
return validatedEdges.some(edge =>
562+
(edge.source === sourceId && edge.target === targetId) ||
563+
(edge.source === targetId && edge.target === sourceId) // Check both directions
564+
);
565+
};
566566

567567
// Define whether to show empty state overlay (but don't early return)
568568
const showEmptyStateOverlay = !loading && !error && nodes.length === 0;
@@ -775,23 +775,59 @@ export function InteractiveGraphVisualization() {
775775
const zoom = d3.zoom<SVGSVGElement, unknown>()
776776
.scaleExtent([0.1, 4]);
777777

778-
svg.call(zoom);
779778
const g = svg.append('g');
780779

781-
// Add background for capturing clicks to create nodes
782-
g.append('rect')
780+
// Add background for capturing clicks to show context menu
781+
const background = g.append('rect')
783782
.attr('class', 'background')
784783
.attr('width', width)
785784
.attr('height', height)
786785
.attr('fill', 'transparent')
787-
.style('cursor', 'crosshair')
788-
.on('click', (event: MouseEvent) => {
789-
// Only create node if clicking on empty space (not dragging)
790-
if (event.defaultPrevented) return;
791-
792-
const [x, y] = d3.pointer(event, g.node());
793-
createInlineNode(x, y);
786+
.style('cursor', 'default');
787+
788+
// Apply zoom behavior to the svg
789+
svg.call(zoom);
790+
791+
// Add click handler for context menu
792+
background.on('click', function(event: MouseEvent) {
793+
event.stopPropagation();
794+
795+
// Close all existing dialogs first (exclusive dialog behavior)
796+
setEditingEdge(null);
797+
798+
// Check if there was already a context menu open - if so, close it and show context menu on next click
799+
if (contextMenuPosition) {
800+
setContextMenuPosition(null);
801+
return; // Don't show menu on this click, wait for next click
802+
}
803+
804+
const [graphX, graphY] = d3.pointer(event, g.node());
805+
806+
// Set the context menu position (screen coordinates for menu, graph coordinates for node creation)
807+
setContextMenuPosition({
808+
x: event.clientX,
809+
y: event.clientY,
810+
graphX,
811+
graphY
812+
});
813+
});
814+
815+
// Add right-click handler for context menu
816+
background.on('contextmenu', function(event: MouseEvent) {
817+
event.preventDefault();
818+
819+
// Close all existing dialogs first (exclusive dialog behavior)
820+
setEditingEdge(null);
821+
822+
const [graphX, graphY] = d3.pointer(event, g.node());
823+
824+
setContextMenuPosition({
825+
x: event.clientX,
826+
y: event.clientY,
827+
graphX,
828+
graphY
794829
});
830+
});
795831

796832
// Initialize all nodes at screen center for 2D layout
797833
nodes.forEach((node: any) => {
@@ -2717,6 +2753,119 @@ export function InteractiveGraphVisualization() {
27172753
</div>
27182754
)}
27192755

2756+
{/* Context Menu */}
2757+
{contextMenuPosition && (
2758+
<div
2759+
className="fixed z-50 bg-gray-800 border border-gray-600 rounded-lg shadow-2xl py-2 min-w-[200px]"
2760+
style={{
2761+
left: Math.min(contextMenuPosition.x, window.innerWidth - 220),
2762+
top: Math.min(contextMenuPosition.y, window.innerHeight - 200)
2763+
}}
2764+
onClick={(e) => e.stopPropagation()}
2765+
>
2766+
<button
2767+
onClick={() => {
2768+
createInlineNode(contextMenuPosition.graphX, contextMenuPosition.graphY);
2769+
setContextMenuPosition(null);
2770+
}}
2771+
className="w-full text-left px-4 py-2 hover:bg-gray-700 text-gray-200 flex items-center space-x-2"
2772+
>
2773+
<Plus className="h-4 w-4" />
2774+
<span>Create Node</span>
2775+
</button>
2776+
2777+
<button
2778+
onClick={() => {
2779+
// Zoom to fit all nodes
2780+
const svg = d3.select(svgRef.current);
2781+
const containerRect = containerRef.current?.getBoundingClientRect();
2782+
if (svg.node() && containerRect && nodes.length > 0) {
2783+
// Find bounds of all nodes using actual simulation positions
2784+
const margin = 150;
2785+
const nodePositions = nodes.map(node => ({
2786+
x: node.x || node.positionX || 0,
2787+
y: node.y || node.positionY || 0
2788+
}));
2789+
2790+
const xExtent = d3.extent(nodePositions, d => d.x) as [number, number];
2791+
const yExtent = d3.extent(nodePositions, d => d.y) as [number, number];
2792+
2793+
const width = containerRect.width;
2794+
const height = containerRect.height;
2795+
2796+
// Calculate the bounding box of all nodes (add padding for node radius)
2797+
const nodeRadius = 50; // Account for node size
2798+
const nodeWidth = Math.max(xExtent[1] - xExtent[0] + 2 * nodeRadius, 300);
2799+
const nodeHeight = Math.max(yExtent[1] - yExtent[0] + 2 * nodeRadius, 300);
2800+
2801+
// Calculate scale to fit all nodes with margin (be more conservative)
2802+
const scaleX = (width - 2 * margin) / nodeWidth;
2803+
const scaleY = (height - 2 * margin) / nodeHeight;
2804+
const scale = Math.min(scaleX, scaleY, 0.8); // Max scale of 0.8x to zoom out more
2805+
2806+
// Calculate center position of all nodes
2807+
const nodesCenterX = (xExtent[0] + xExtent[1]) / 2;
2808+
const nodesCenterY = (yExtent[0] + yExtent[1]) / 2;
2809+
2810+
// Calculate screen center
2811+
const screenCenterX = width / 2;
2812+
const screenCenterY = height / 2;
2813+
2814+
// Calculate translation to put the center of nodes at the center of the screen
2815+
// Formula: translate = screenCenter - (nodeCenter * scale)
2816+
const translateX = screenCenterX - nodesCenterX * scale;
2817+
const translateY = screenCenterY - nodesCenterY * scale;
2818+
2819+
console.log('Zoom to fit:', {
2820+
scale,
2821+
translateX,
2822+
translateY,
2823+
nodesCenterX,
2824+
nodesCenterY,
2825+
screenCenterX,
2826+
screenCenterY,
2827+
width,
2828+
height,
2829+
xExtent,
2830+
yExtent
2831+
});
2832+
2833+
// Create the transform and update D3's zoom behavior state properly
2834+
const newTransform = d3.zoomIdentity.translate(translateX, translateY).scale(scale);
2835+
2836+
// Update D3's internal zoom state immediately (no transition)
2837+
svg.property('__zoom', newTransform);
2838+
2839+
// Apply the visual transform with transition
2840+
const g = svg.select('g');
2841+
g.transition()
2842+
.duration(750)
2843+
.attr('transform', newTransform.toString())
2844+
.on('end', () => {
2845+
// Update the current transform state
2846+
setCurrentTransform({ x: translateX, y: translateY, scale: scale });
2847+
});
2848+
}
2849+
setContextMenuPosition(null);
2850+
}}
2851+
className="w-full text-left px-4 py-2 hover:bg-gray-700 text-gray-200 flex items-center space-x-2"
2852+
>
2853+
<div className="h-4 w-4 flex items-center justify-center">
2854+
<div className="w-3 h-3 border border-gray-400 rounded"></div>
2855+
</div>
2856+
<span>Zoom to Fit</span>
2857+
</button>
2858+
</div>
2859+
)}
2860+
2861+
{/* Click outside handler for context menu */}
2862+
{contextMenuPosition && (
2863+
<div
2864+
className="fixed inset-0 z-[40]"
2865+
onClick={() => setContextMenuPosition(null)}
2866+
/>
2867+
)}
2868+
27202869
{/* Modern Inline Edge Type Editor Dropdown */}
27212870
{editingEdge && (
27222871
<div

0 commit comments

Comments
 (0)