@@ -20,6 +20,7 @@ import * as React from 'react';
2020
2121import { Button } from '@mui/material' ;
2222import { useTheme } from '@mui/material/styles' ;
23+ import { createPortal } from 'react-dom' ;
2324import { useHistory } from 'react-router-dom' ;
2425import {
2526 data as visData ,
@@ -164,23 +165,9 @@ export const Graph = React.memo<GraphProps>(({
164165 const [ hierarchyEnabled , setHierarchyEnabled ] = React . useState < boolean > ( enableDefaultHierarchy ) ;
165166 const [ network , setNetwork ] = React . useState < Network > ( null ) ;
166167 const [ graph , setGraph ] = React . useState < GraphData > ( null ) ;
167-
168- // Movable edge-label overlay state.
169- // edgeLabels: per-edge label text (built from edgeLabelColumn).
170- // selfLoopAngles: starting angle around the node for each self-loop's
171- // label so multiple loops on the same pod don't stack on top of
172- // each other before any user dragging.
173- // labelOffsets: persistent drag-offset per edge id, in DOM px.
174- // labelLayout: most-recent DOM-space label positions for rendering.
175- const [ edgeLabels , setEdgeLabels ] = React . useState < Map < string , string > > ( ( ) => new Map ( ) ) ;
176- const [ edgeSelfLoopAngles , setEdgeSelfLoopAngles ] = React . useState < Map < string , number > > ( ( ) => new Map ( ) ) ;
177- const [ labelOffsets , setLabelOffsets ] = React . useState < Map < string , { dx : number , dy : number } > > ( ( ) => new Map ( ) ) ;
178- const [ labelLayout , setLabelLayout ] = React . useState < Array < {
179- id : string ,
180- text : string ,
181- x : number ,
182- y : number ,
183- } > > ( [ ] ) ;
168+ const [ pinned , setPinned ] = React . useState < {
169+ label : string , title : string , x : number , y : number ,
170+ } | null > ( null ) ;
184171
185172 const { embedState } = React . useContext ( LiveRouteContext ) ;
186173
@@ -213,13 +200,6 @@ export const Graph = React.memo<GraphProps>(({
213200 const edges = new visData . DataSet < Edge > ( ) ;
214201 const nodes = new visData . DataSet < Node > ( ) ;
215202 const idToSemType = { } ;
216- // Per-edge label text — rendered as a draggable HTML overlay in
217- // the JSX below so users can pull stacked labels (esp. on self-
218- // loops) apart and read what's underneath. vis-network's native
219- // edge label is suppressed for any edge that lands here.
220- const labelMap = new Map < string , string > ( ) ;
221- const selfLoopCounts = new Map < string , number > ( ) ;
222- const selfLoopAngles = new Map < string , number > ( ) ;
223203
224204 const upsertNode = ( label : string , st : SemanticType , weight : number ) => {
225205 if ( ! idToSemType [ label ] ) {
@@ -237,7 +217,7 @@ export const Graph = React.memo<GraphProps>(({
237217 idToSemType [ label ] = st ;
238218 }
239219 } ;
240- data . forEach ( ( d , idx ) => {
220+ data . forEach ( ( d ) => {
241221 const nt = d [ toCol . name ] ;
242222 const nf = d [ fromCol . name ] ;
243223
@@ -249,11 +229,7 @@ export const Graph = React.memo<GraphProps>(({
249229 upsertNode ( nt , toCol ?. semType , nodeWeight ) ;
250230 upsertNode ( nf , fromCol ?. semType , nodeWeight ) ;
251231
252- // Stable per-row id so the overlay can keep its drag offset
253- // attached even when the underlying network re-stabilises.
254- const edgeId = `e${ idx } ` ;
255232 const edge = {
256- id : edgeId ,
257233 from : nf ,
258234 to : nt ,
259235 } as Edge ;
@@ -268,16 +244,7 @@ export const Graph = React.memo<GraphProps>(({
268244 }
269245
270246 if ( edgeLabelColumn ) {
271- // DON'T set edge.label — the overlay renders it. Track text +
272- // (for self-loops) a per-edge angle so labels around the same
273- // pod start at distinct positions before the user drags them.
274- labelMap . set ( edgeId , String ( d [ edgeLabelColumn . name ] ) ) ;
275- if ( nf === nt ) {
276- const seen = selfLoopCounts . get ( nf ) || 0 ;
277- selfLoopCounts . set ( nf , seen + 1 ) ;
278- // Fan out around the node: 60° apart, starting at 30°.
279- selfLoopAngles . set ( edgeId , ( ( seen * Math . PI ) / 3 ) + ( Math . PI / 6 ) ) ;
280- }
247+ edge . label = String ( d [ edgeLabelColumn . name ] ) ;
281248 }
282249
283250 if ( edgeHoverInfo && edgeHoverInfo . length > 0 ) {
@@ -303,8 +270,6 @@ export const Graph = React.memo<GraphProps>(({
303270 setGraph ( {
304271 nodes, edges, idToSemType,
305272 } ) ;
306- setEdgeLabels ( labelMap ) ;
307- setEdgeSelfLoopAngles ( selfLoopAngles ) ;
308273 // eslint-disable-next-line react-hooks/exhaustive-deps
309274 } , [ dot , data , toCol , fromCol ] ) ;
310275
@@ -329,6 +294,21 @@ export const Graph = React.memo<GraphProps>(({
329294 const n = new Network ( ref . current , graph , opts ) ;
330295 n . on ( 'doubleClick' , doubleClickCallback ) ;
331296
297+ n . on ( 'click' , ( params : any ) => {
298+ if ( params . edges . length > 0 && params . nodes . length === 0 ) {
299+ const edgeData : any = graph . edges . get ( params . edges [ 0 ] ) ;
300+ const rect = ref . current . getBoundingClientRect ( ) ;
301+ setPinned ( {
302+ label : String ( edgeData ?. label ?? '' ) ,
303+ title : String ( edgeData ?. title ?? '' ) ,
304+ x : rect . left + params . pointer . DOM . x ,
305+ y : rect . top + params . pointer . DOM . y ,
306+ } ) ;
307+ } else {
308+ setPinned ( null ) ;
309+ }
310+ } ) ;
311+
332312 n . on ( 'stabilizationIterationsDone' , ( ) => {
333313 n . setOptions ( { physics : false } ) ;
334314 } ) ;
@@ -337,82 +317,6 @@ export const Graph = React.memo<GraphProps>(({
337317 // eslint-disable-next-line react-hooks/exhaustive-deps
338318 } , [ graph , doubleClickCallback , hierarchyEnabled ] ) ;
339319
340- // Recompute label DOM positions whenever the network repaints
341- // (drag, zoom, physics tick). canvasToDOM gives us the on-screen
342- // pixel for the canvas-space midpoint of each edge.
343- React . useEffect ( ( ) => {
344- if ( ! network || edgeLabels . size === 0 ) {
345- setLabelLayout ( [ ] ) ;
346- return undefined ;
347- }
348- let raf = 0 ;
349- const recompute = ( ) => {
350- if ( raf ) cancelAnimationFrame ( raf ) ;
351- raf = requestAnimationFrame ( ( ) => {
352- const next : Array < { id : string , text : string , x : number , y : number } > = [ ] ;
353- edgeLabels . forEach ( ( text , edgeId ) => {
354- // getConnectedNodes(edgeId) returns [from, to] for a normal
355- // edge and [self] for a self-loop. Used instead of reaching
356- // into network.body (which the public typings don't expose).
357- const ends = network . getConnectedNodes ( edgeId ) as Array < string | number > ;
358- if ( ! ends || ends . length === 0 ) return ;
359- const fromId = String ( ends [ 0 ] ) ;
360- const toId = String ( ends . length > 1 ? ends [ 1 ] : ends [ 0 ] ) ;
361- const fromPos = network . getPositions ( [ fromId ] ) [ fromId ] ;
362- const toPos = network . getPositions ( [ toId ] ) [ toId ] ;
363- if ( ! fromPos || ! toPos ) return ;
364- let cx : number ;
365- let cy : number ;
366- if ( fromId === toId ) {
367- // Self-loop: pin label to a per-edge angle around the node
368- // so two loops on the same pod don't start in the same spot.
369- const angle = edgeSelfLoopAngles . get ( edgeId ) || 0 ;
370- const radius = 55 ;
371- cx = fromPos . x + Math . cos ( angle ) * radius ;
372- cy = fromPos . y + Math . sin ( angle ) * radius ;
373- } else {
374- cx = ( fromPos . x + toPos . x ) / 2 ;
375- cy = ( fromPos . y + toPos . y ) / 2 ;
376- }
377- const dom = network . canvasToDOM ( { x : cx , y : cy } ) ;
378- const off = labelOffsets . get ( edgeId ) || { dx : 0 , dy : 0 } ;
379- next . push ( { id : edgeId , text, x : dom . x + off . dx , y : dom . y + off . dy } ) ;
380- } ) ;
381- setLabelLayout ( next ) ;
382- } ) ;
383- } ;
384- network . on ( 'afterDrawing' , recompute ) ;
385- recompute ( ) ;
386- return ( ) => {
387- network . off ( 'afterDrawing' , recompute ) ;
388- if ( raf ) cancelAnimationFrame ( raf ) ;
389- } ;
390- } , [ network , edgeLabels , edgeSelfLoopAngles , labelOffsets ] ) ;
391-
392- const onLabelPointerDown = React . useCallback ( ( edgeId : string ) => ( e : React . PointerEvent ) => {
393- e . stopPropagation ( ) ;
394- e . preventDefault ( ) ;
395- const startX = e . clientX ;
396- const startY = e . clientY ;
397- const initial = labelOffsets . get ( edgeId ) || { dx : 0 , dy : 0 } ;
398- const move = ( ev : PointerEvent ) => {
399- setLabelOffsets ( ( prev ) => {
400- const nextMap = new Map ( prev ) ;
401- nextMap . set ( edgeId , {
402- dx : initial . dx + ev . clientX - startX ,
403- dy : initial . dy + ev . clientY - startY ,
404- } ) ;
405- return nextMap ;
406- } ) ;
407- } ;
408- const up = ( ) => {
409- window . removeEventListener ( 'pointermove' , move ) ;
410- window . removeEventListener ( 'pointerup' , up ) ;
411- } ;
412- window . addEventListener ( 'pointermove' , move ) ;
413- window . addEventListener ( 'pointerup' , up ) ;
414- } , [ labelOffsets ] ) ;
415-
416320 const controls = React . useMemo ( ( ) => (
417321 < Button
418322 size = 'small'
@@ -423,50 +327,47 @@ export const Graph = React.memo<GraphProps>(({
423327 ) , [ hierarchyEnabled , toggleHierarchy ] ) ;
424328
425329 return (
426- < div style = { { position : 'relative' , width : '100%' , height : '100%' } } >
330+ < >
427331 < GraphBase
428332 network = { network }
429333 visRootRef = { ref }
430334 showZoomButtons = { true }
431335 setExternalControls = { setExternalControls }
432336 additionalButtons = { controls }
433337 />
434- < div style = { {
435- position : 'absolute' ,
436- top : 0 ,
437- left : 0 ,
438- right : 0 ,
439- bottom : 0 ,
440- pointerEvents : 'none' ,
441- overflow : 'hidden' ,
442- } } >
443- { labelLayout . map ( ( { id, text, x, y } ) => (
444- < div
445- key = { id }
446- onPointerDown = { onLabelPointerDown ( id ) }
447- style = { {
448- position : 'absolute' ,
449- left : `${ x } px` ,
450- top : `${ y } px` ,
451- transform : 'translate(-50%, -50%)' ,
452- padding : '1px 6px' ,
453- background : 'rgba(38, 38, 42, 0.85)' ,
454- color : '#ffffff' ,
455- fontSize : '11px' ,
456- fontFamily : 'Roboto, sans-serif' ,
457- borderRadius : '3px' ,
458- cursor : 'grab' ,
459- pointerEvents : 'auto' ,
460- userSelect : 'none' ,
461- whiteSpace : 'nowrap' ,
462- touchAction : 'none' ,
463- } }
464- >
465- { text }
338+ { pinned && createPortal (
339+ < div
340+ onClick = { ( e ) => e . stopPropagation ( ) }
341+ style = { {
342+ position : 'fixed' ,
343+ left : `${ pinned . x + 12 } px` ,
344+ top : `${ pinned . y + 12 } px` ,
345+ background : 'rgba(38,38,42,0.95)' ,
346+ color : '#fff' ,
347+ padding : '8px 12px' ,
348+ borderRadius : '4px' ,
349+ fontFamily : 'Roboto, sans-serif' ,
350+ fontSize : '12px' ,
351+ maxWidth : '320px' ,
352+ zIndex : 9999 ,
353+ boxShadow : '0 2px 8px rgba(0,0,0,0.4)' ,
354+ } }
355+ >
356+ < div style = { {
357+ display : 'flex' , justifyContent : 'space-between' , gap : '8px' ,
358+ fontWeight : 'bold' , marginBottom : '4px' ,
359+ } } >
360+ < span > { pinned . label } </ span >
361+ < span
362+ onClick = { ( ) => setPinned ( null ) }
363+ style = { { cursor : 'pointer' , opacity : 0.7 } }
364+ > ×</ span >
466365 </ div >
467- ) ) }
468- </ div >
469- </ div >
366+ < div dangerouslySetInnerHTML = { { __html : pinned . title } } />
367+ </ div > ,
368+ document . body ,
369+ ) }
370+ </ >
470371 ) ;
471372} ) ;
472373Graph . displayName = 'Graph' ;
0 commit comments