@@ -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