@@ -220,11 +220,11 @@ export default function RelationGraph() {
220220 }
221221
222222 const nodeArr = Array . from ( nodes . values ( ) ) ;
223- const REPULSION = 2000 ;
224- const ATTRACTION = 0.008 ;
223+ const REPULSION = 5000 ;
224+ const ATTRACTION = 0.005 ;
225225 const DAMPING = 0.55 ;
226- const CENTER_GRAVITY = 0.02 ;
227- const IDEAL_DIST = 140 ;
226+ const CENTER_GRAVITY = 0.01 ;
227+ const IDEAL_DIST = 200 ;
228228 const MAX_VELOCITY = 5 ;
229229 const ALPHA_DECAY = 0.995 ;
230230 const ALPHA_MIN = 0.001 ;
@@ -552,6 +552,101 @@ export default function RelationGraph() {
552552 return ( ) => canvas . removeEventListener ( 'wheel' , handler ) ;
553553 } , [ filteredRelations ] ) ;
554554
555+ // ── Touch interaction (pinch zoom + drag) ──
556+
557+ const touchRef = useRef < { startDist : number ; startScale : number ; startX : number ; startY : number ; startTx : number ; startTy : number ; fingers : number } > ( { startDist : 0 , startScale : 1 , startX : 0 , startY : 0 , startTx : 0 , startTy : 0 , fingers : 0 } ) ;
558+
559+ const getTouchDist = ( touches : React . TouchList ) => {
560+ if ( touches . length < 2 ) return 0 ;
561+ const dx = touches [ 1 ] ! . clientX - touches [ 0 ] ! . clientX ;
562+ const dy = touches [ 1 ] ! . clientY - touches [ 0 ] ! . clientY ;
563+ return Math . sqrt ( dx * dx + dy * dy ) ;
564+ } ;
565+
566+ const getTouchCenter = ( touches : React . TouchList ) => {
567+ if ( touches . length < 2 ) {
568+ const canvas = canvasRef . current ! ;
569+ const rect = canvas . getBoundingClientRect ( ) ;
570+ return {
571+ cx : ( touches [ 0 ] ! . clientX - rect . left ) * ( canvas . width / rect . width ) ,
572+ cy : ( touches [ 0 ] ! . clientY - rect . top ) * ( canvas . height / rect . height ) ,
573+ } ;
574+ }
575+ const canvas = canvasRef . current ! ;
576+ const rect = canvas . getBoundingClientRect ( ) ;
577+ return {
578+ cx : ( ( touches [ 0 ] ! . clientX + touches [ 1 ] ! . clientX ) / 2 - rect . left ) * ( canvas . width / rect . width ) ,
579+ cy : ( ( touches [ 0 ] ! . clientY + touches [ 1 ] ! . clientY ) / 2 - rect . top ) * ( canvas . height / rect . height ) ,
580+ } ;
581+ } ;
582+
583+ const onTouchStart = ( e : React . TouchEvent < HTMLCanvasElement > ) => {
584+ e . preventDefault ( ) ;
585+ const t = transformRef . current ;
586+ const { cx, cy } = getTouchCenter ( e . touches ) ;
587+ touchRef . current = {
588+ startDist : getTouchDist ( e . touches ) ,
589+ startScale : t . scale ,
590+ startX : cx , startY : cy ,
591+ startTx : t . offsetX , startTy : t . offsetY ,
592+ fingers : e . touches . length ,
593+ } ;
594+
595+ // Single finger: check node drag
596+ if ( e . touches . length === 1 ) {
597+ const { wx, wy } = canvasToWorld ( cx , cy ) ;
598+ const nodeId = getNodeAt ( wx , wy ) ;
599+ if ( nodeId ) {
600+ const n = nodesRef . current . get ( nodeId ) ! ;
601+ dragRef . current = { nodeId, offsetX : n . x - wx , offsetY : n . y - wy , isPanning : false , startX : 0 , startY : 0 , startTx : 0 , startTy : 0 } ;
602+ }
603+ }
604+ } ;
605+
606+ const onTouchMove = ( e : React . TouchEvent < HTMLCanvasElement > ) => {
607+ e . preventDefault ( ) ;
608+ const tr = touchRef . current ;
609+ const { cx, cy } = getTouchCenter ( e . touches ) ;
610+
611+ if ( e . touches . length >= 2 ) {
612+ // Pinch zoom
613+ const dist = getTouchDist ( e . touches ) ;
614+ if ( tr . startDist > 0 ) {
615+ const ratio = dist / tr . startDist ;
616+ const newScale = Math . min ( Math . max ( tr . startScale * ratio , 0.1 ) , 8 ) ;
617+ const wx = ( tr . startX - tr . startTx ) / tr . startScale ;
618+ const wy = ( tr . startY - tr . startTy ) / tr . startScale ;
619+ transformRef . current = {
620+ scale : newScale ,
621+ offsetX : cx - wx * newScale + ( cx - tr . startX ) ,
622+ offsetY : cy - wy * newScale + ( cy - tr . startY ) ,
623+ } ;
624+ }
625+ } else if ( e . touches . length === 1 ) {
626+ if ( dragRef . current . nodeId ) {
627+ // Drag node
628+ const { wx, wy } = canvasToWorld ( cx , cy ) ;
629+ const n = nodesRef . current . get ( dragRef . current . nodeId ) ;
630+ if ( n ) { n . x = wx + dragRef . current . offsetX ; n . y = wy + dragRef . current . offsetY ; n . vx = 0 ; n . vy = 0 ; }
631+ } else {
632+ // Pan
633+ transformRef . current = {
634+ ...transformRef . current ,
635+ offsetX : tr . startTx + ( cx - tr . startX ) ,
636+ offsetY : tr . startTy + ( cy - tr . startY ) ,
637+ } ;
638+ }
639+ }
640+ } ;
641+
642+ const onTouchEnd = ( e : React . TouchEvent < HTMLCanvasElement > ) => {
643+ e . preventDefault ( ) ;
644+ if ( dragRef . current . nodeId ) {
645+ alphaRef . current = Math . max ( alphaRef . current , 0.3 ) ;
646+ }
647+ dragRef . current = { nodeId : null , offsetX : 0 , offsetY : 0 , isPanning : false , startX : 0 , startY : 0 , startTx : 0 , startTy : 0 } ;
648+ } ;
649+
555650 // Node stats
556651 const nodeSet = new Set < string > ( ) ;
557652 filteredRelations . forEach ( r => { nodeSet . add ( r . subject ) ; nodeSet . add ( r . object ) ; } ) ;
@@ -563,57 +658,61 @@ export default function RelationGraph() {
563658
564659 return (
565660 < div >
566- < div style = { { display : 'flex' , justifyContent : 'space-between' , alignItems : 'center' , marginBottom : 16 } } >
567- < h1 className = "page-title" style = { { marginBottom : 0 } } > { t ( 'relations.title' ) } </ h1 >
568- < button className = "btn primary" onClick = { ( ) => setCreating ( true ) } > { t ( 'relations.newRelation' ) } </ button >
569- </ div >
661+ < h1 className = "page-title" > { t ( 'relations.title' ) } </ h1 >
570662
571- { /* Filters */ }
572- { predicates . length > 0 && (
573- < div className = "toolbar" >
574- < select value = { predicateFilter } onChange = { e => setPredicateFilter ( e . target . value ) } style = { { width : 'auto' } } >
663+ { /* Toolbar */ }
664+ < div className = "toolbar" >
665+ { predicates . length > 0 && (
666+ < select value = { predicateFilter } onChange = { e => setPredicateFilter ( e . target . value ) } >
575667 < option value = "" > { t ( 'relations.allPredicates' , { count : predicates . length } ) } </ option >
576668 { predicates . map ( p => (
577669 < option key = { p } value = { p } > { p } ({ relations . filter ( r => r . predicate === p ) . length } )</ option >
578670 ) ) }
579671 </ select >
580- { selectedNode && (
581- < button className = "btn" onClick = { ( ) => { setSelectedNode ( null ) ; setNodeMemories ( [ ] ) ; } } style = { { fontSize : 12 } } >
582- { t ( 'relations.deselect' , { node : selectedNode } ) }
583- </ button >
584- ) }
585- < div style = { { marginLeft : 'auto' , display : 'flex' , alignItems : 'center' , gap : 12 , flexWrap : 'wrap' } } >
586- < button className = "btn" onClick = { fitToView } style = { { fontSize : 12 , padding : '4px 10px' } } >
587- { t ( 'relations.fit' ) }
588- </ button >
589- < label style = { { display : 'flex' , alignItems : 'center' , gap : 6 , fontSize : 12 , color : 'var(--text-muted)' , cursor : 'pointer' , whiteSpace : 'nowrap' } } >
590- < input type = "checkbox" checked = { autoRefresh } onChange = { e => setAutoRefresh ( e . target . checked ) } />
591- { t ( 'relations.autoRefresh' ) }
592- </ label >
593- < span style = { { color : 'var(--text-muted)' , fontSize : 13 , whiteSpace : 'nowrap' } } >
594- { t ( 'relations.nodeEdgeCount' , { nodes : nodeSet . size , edges : filteredRelations . length } ) }
595- </ span >
596- </ div >
597- </ div >
598- ) }
672+ ) }
673+ { selectedNode && (
674+ < button className = "btn" onClick = { ( ) => { setSelectedNode ( null ) ; setNodeMemories ( [ ] ) ; } } style = { { fontSize : 12 } } >
675+ ✕ { selectedNode }
676+ </ button >
677+ ) }
678+ < button className = "btn" onClick = { ( ) => setAutoRefresh ( ! autoRefresh ) } style = { { fontSize : 11 , padding : '2px 8px' } } >
679+ { autoRefresh ? '⏸' : '▶' } { t ( 'relations.autoRefresh' ) }
680+ </ button >
681+ < span style = { { fontSize : 12 , color : 'var(--text-muted)' } } >
682+ { t ( 'relations.nodeEdgeCount' , { nodes : nodeSet . size , edges : filteredRelations . length } ) }
683+ </ span >
684+ < div style = { { flex : 1 } } />
685+ < button className = "btn primary" onClick = { ( ) => setCreating ( true ) } > { t ( 'relations.newRelation' ) } </ button >
686+ </ div >
599687
600688 { /* Force-directed graph */ }
601689 { filteredRelations . length > 0 && (
602- < div className = "card" style = { { marginBottom : 16 } } >
690+ < div className = "card" style = { { marginBottom : 16 , position : 'relative' } } >
603691 < canvas
604692 ref = { canvasRef }
605693 width = { 900 }
606694 height = { 500 }
607- style = { { width : '100%' , height : 'auto' , background : '#0f0f1a' , borderRadius : 8 , cursor : dragRef . current . nodeId ? 'grabbing' : dragRef . current . isPanning ? 'grabbing' : 'grab' } }
695+ style = { { width : '100%' , height : 'auto' , background : '#0f0f1a' , borderRadius : 8 , cursor : dragRef . current . nodeId ? 'grabbing' : dragRef . current . isPanning ? 'grabbing' : 'grab' , touchAction : 'none' } }
608696 onMouseDown = { onMouseDown }
609697 onMouseMove = { onMouseMove }
610698 onMouseUp = { onMouseUp }
611699 onMouseLeave = { onMouseLeave }
700+ onDoubleClick = { ( ) => { fitToView ( ) ; alphaRef . current = Math . max ( alphaRef . current , 0.3 ) ; } }
701+ onTouchStart = { onTouchStart }
702+ onTouchMove = { onTouchMove }
703+ onTouchEnd = { onTouchEnd }
612704 />
705+ { /* Zoom controls - bottom right */ }
706+ < div style = { { position : 'absolute' , bottom : 40 , right : 16 , display : 'flex' , gap : 4 , background : 'rgba(15,15,26,0.8)' , borderRadius : 6 , padding : 4 } } >
707+ < button style = { { width : 28 , height : 28 , border : '1px solid rgba(255,255,255,0.15)' , borderRadius : 4 , background : 'rgba(255,255,255,0.08)' , color : '#ccc' , fontSize : 16 , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } }
708+ onClick = { ( ) => { const t = transformRef . current ; const cx = canvasRef . current ! . width / 2 ; const cy = canvasRef . current ! . height / 2 ; const wx = ( cx - t . offsetX ) / t . scale ; const wy = ( cy - t . offsetY ) / t . scale ; const ns = Math . min ( t . scale * 1.3 , 8 ) ; transformRef . current = { scale : ns , offsetX : cx - wx * ns , offsetY : cy - wy * ns } ; } } > +</ button >
709+ < button style = { { width : 28 , height : 28 , border : '1px solid rgba(255,255,255,0.15)' , borderRadius : 4 , background : 'rgba(255,255,255,0.08)' , color : '#ccc' , fontSize : 16 , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } }
710+ onClick = { ( ) => { const t = transformRef . current ; const cx = canvasRef . current ! . width / 2 ; const cy = canvasRef . current ! . height / 2 ; const wx = ( cx - t . offsetX ) / t . scale ; const wy = ( cy - t . offsetY ) / t . scale ; const ns = Math . max ( t . scale / 1.3 , 0.1 ) ; transformRef . current = { scale : ns , offsetX : cx - wx * ns , offsetY : cy - wy * ns } ; } } > −</ button >
711+ < button style = { { width : 28 , height : 28 , border : '1px solid rgba(255,255,255,0.15)' , borderRadius : 4 , background : 'rgba(255,255,255,0.08)' , color : '#ccc' , fontSize : 11 , cursor : 'pointer' , display : 'flex' , alignItems : 'center' , justifyContent : 'center' } }
712+ onClick = { ( ) => { fitToView ( ) ; alphaRef . current = Math . max ( alphaRef . current , 0.3 ) ; } } > Fit</ button >
713+ </ div >
613714 < p style = { { fontSize : 12 , color : 'var(--text-muted)' , marginTop : 6 } } >
614- { t ( 'relations.graphHint' ) }
615- { ' ' } { t ( 'relations.lineThickness' ) }
616- { ' ' } { t ( 'relations.zoomHint' ) }
715+ { t ( 'relations.graphHint' ) } { t ( 'relations.zoomHint' ) }
617716 </ p >
618717 { tooMany && (
619718 < p style = { { fontSize : 12 , color : '#f59e0b' , marginTop : 4 } } >
0 commit comments