1414 * limitations under the License.
1515 */
1616
17- import { useRef , useEffect , useMemo , useState , useCallback } from 'react' ;
17+ import { useRef , useEffect , useMemo , useState , useCallback , useLayoutEffect } from 'react' ;
1818import * as d3 from 'd3' ;
1919import { FlowEntry , entryBitRate } from '../store/flow-store' ;
2020import {
@@ -285,8 +285,7 @@ interface D3Link extends d3.SimulationLinkDatum<D3Node> {
285285 curveOffset : number ;
286286}
287287
288- const WIDTH = 1100 ;
289- const HEIGHT = 700 ;
288+ const HEIGHT = 900 ;
290289const NODE_RX = 8 ;
291290const NODE_PADDING_X = 14 ;
292291const NODE_PADDING_Y = 8 ;
@@ -310,20 +309,26 @@ function EdgeDetailsPanel({ details, onClose }: { details: EdgeDetails; onClose:
310309 maxWidth : '350px' ,
311310 zIndex : 10 ,
312311 } } >
313- < div cds-layout = "horizontal justify:space-between align:vertical-center" style = { { marginBottom : '12px' } } >
312+ < button
313+ onClick = { onClose }
314+ title = "Close"
315+ style = { {
316+ position : 'absolute' ,
317+ top : '8px' ,
318+ right : '8px' ,
319+ background : 'none' ,
320+ border : 'none' ,
321+ color : 'var(--cds-alias-object-border-color, #565656)' ,
322+ cursor : 'pointer' ,
323+ fontSize : '14px' ,
324+ lineHeight : 1 ,
325+ padding : '2px' ,
326+ } }
327+ >
328+ ✕
329+ </ button >
330+ < div style = { { marginBottom : '12px' } } >
314331 < span cds-text = "section" > Connection Stats</ span >
315- < button
316- onClick = { onClose }
317- style = { {
318- background : 'none' ,
319- border : 'none' ,
320- color : 'var(--cds-global-typography-color-400, #fff)' ,
321- cursor : 'pointer' ,
322- fontSize : '18px' ,
323- } }
324- >
325- ✕
326- </ button >
327332 </ div >
328333 < div cds-layout = "vertical gap:sm" >
329334 < div > < strong > Source:</ strong > { getWorkloadShortName ( details . source ) } </ div >
@@ -416,20 +421,52 @@ function nodeBoundaryPoint(d: D3Node, towardX: number, towardY: number, gap: num
416421}
417422
418423export default function ServiceMap ( { entries } : ServiceMapProps ) {
424+ const containerRef = useRef < HTMLDivElement > ( null ) ;
419425 const svgRef = useRef < SVGSVGElement > ( null ) ;
420426 const tooltipRef = useRef < HTMLDivElement > ( null ) ;
421427 const simulationRef = useRef < d3 . Simulation < D3Node , D3Link > | null > ( null ) ;
422- const [ selectedEdge , setSelectedEdge ] = useState < EdgeDetails | null > ( null ) ;
428+ const [ selectedEdgeKey , setSelectedEdgeKey ] = useState < string | null > ( null ) ;
429+ const [ svgWidth , setSvgWidth ] = useState ( 1100 ) ;
430+
431+ useLayoutEffect ( ( ) => {
432+ const el = containerRef . current ;
433+ if ( ! el ) return ;
434+ // Measure a guaranteed full-width block ancestor (the page <main>) rather
435+ // than the container itself. The container lives inside a Clarity
436+ // cds-layout="vertical" flex column whose cross-axis sizing can shrink-wrap
437+ // the container to the SVG's own pixel width, which would pin svgWidth near
438+ // its default (a feedback loop). <main> is display:block and always fills
439+ // its flex:1 parent, so its clientWidth is the true available width.
440+ const measureTarget : HTMLElement = el . closest ( 'main' ) ?? el . parentElement ?? el ;
441+ const measure = ( ) => {
442+ const w = measureTarget . clientWidth ;
443+ if ( w && w > 0 ) setSvgWidth ( Math . floor ( w ) ) ;
444+ } ;
445+ const ro = new ResizeObserver ( measure ) ;
446+ ro . observe ( measureTarget ) ;
447+ measure ( ) ;
448+ return ( ) => ro . disconnect ( ) ;
449+ } , [ ] ) ;
423450
424451 const graph = useMemo ( ( ) => buildGraph ( entries ) , [ entries ] ) ;
425452
453+ // Keep a ref to the latest graph so D3 event handlers (bound once at
454+ // topology-build time) always read fresh edge data without needing to be
455+ // re-registered on every flow update.
456+ const graphRef = useRef ( graph ) ;
457+ graphRef . current = graph ;
458+
459+ const selectedEdge = selectedEdgeKey !== null
460+ ? ( graph . edgeMap . has ( selectedEdgeKey ) ? edgeToDetails ( graph . edgeMap . get ( selectedEdgeKey ) ! ) : null )
461+ : null ;
462+
426463 const prevTopologyRef = useRef < string > ( '' ) ;
427464
428465 const topologyKey = useMemo ( ( ) => {
429466 const nodeIds = graph . nodes . map ( n => n . id ) . sort ( ) . join ( ',' ) ;
430467 const edgeIds = graph . edges . map ( e => `${ e . source } |${ e . target } ` ) . sort ( ) . join ( ',' ) ;
431- return `${ nodeIds } ::${ edgeIds } ` ;
432- } , [ graph ] ) ;
468+ return `${ nodeIds } ::${ edgeIds } :: ${ svgWidth } ` ;
469+ } , [ graph , svgWidth ] ) ;
433470
434471 const topologyChanged = topologyKey !== prevTopologyRef . current ;
435472
@@ -482,6 +519,16 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
482519 if ( tip ) tip . style . opacity = '0' ;
483520 } , [ ] ) ;
484521
522+ // Dismiss the hover tooltip and the click-selected edge panel whenever the
523+ // flow store is cleared (filter applied/reset/clear), so stale data from the
524+ // previous filter is not shown alongside the new results.
525+ useEffect ( ( ) => {
526+ if ( entries . length === 0 ) {
527+ hideTooltip ( ) ;
528+ setSelectedEdgeKey ( null ) ;
529+ }
530+ } , [ entries . length , hideTooltip ] ) ;
531+
485532 useEffect ( ( ) => {
486533 if ( ! svgRef . current ) return ;
487534
@@ -593,7 +640,7 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
593640 d3 . select ( this )
594641 . attr ( 'stroke-opacity' , 1 )
595642 . attr ( 'stroke-width' , Math . min ( 1.5 + Math . log2 ( d . connectionCount + 1 ) , 6 ) + 1.5 ) ;
596- const edgeData = graph . edgeMap . get ( d . edgeKey ) ;
643+ const edgeData = graphRef . current . edgeMap . get ( d . edgeKey ) ;
597644 if ( edgeData ) showTooltip ( event , edgeData ) ;
598645 } )
599646 . on ( 'mousemove' , function ( event ) {
@@ -610,8 +657,8 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
610657 hideTooltip ( ) ;
611658 } )
612659 . on ( 'click' , ( _event , d ) => {
613- const edgeData = graph . edgeMap . get ( d . edgeKey ) ;
614- if ( edgeData ) setSelectedEdge ( edgeToDetails ( edgeData ) ) ;
660+ const edgeData = graphRef . current . edgeMap . get ( d . edgeKey ) ;
661+ if ( edgeData ) setSelectedEdgeKey ( d . edgeKey ) ;
615662 } ) ;
616663
617664 const edgeLabelGroup = container . append ( 'g' ) ;
@@ -653,10 +700,18 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
653700 } )
654701 . on ( 'end' , ( event , d ) => {
655702 if ( ! event . active ) simulation . alphaTarget ( 0 ) ;
656- d . fx = null ;
657- d . fy = null ;
703+ // Keep fx/fy set so the node stays where the user placed it.
704+ // Double-click releases the pin.
705+ d . fx = d . x ;
706+ d . fy = d . y ;
658707 } )
659- ) ;
708+ )
709+ . on ( 'dblclick' , ( _event , d ) => {
710+ d . fx = null ;
711+ d . fy = null ;
712+ simulation . alphaTarget ( 0.3 ) . restart ( ) ;
713+ setTimeout ( ( ) => simulation . alphaTarget ( 0 ) , 1500 ) ;
714+ } ) ;
660715
661716 node . each ( function ( d ) {
662717 const g = d3 . select ( this ) ;
@@ -746,11 +801,40 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
746801 return Math . max ( nameW , nsW ) / 2 + NODE_PADDING_X + 4 ;
747802 }
748803
804+ // Custom force: pull same-namespace nodes toward their namespace centroid.
805+ function forceNamespaceClustering ( alpha : number ) {
806+ const centroids = new Map < string , { x : number ; y : number ; count : number } > ( ) ;
807+ for ( const n of d3Nodes ) {
808+ if ( ! n . namespace || n . isExternal ) continue ;
809+ const c = centroids . get ( n . namespace ) ;
810+ if ( c ) {
811+ c . x += n . x ! ;
812+ c . y += n . y ! ;
813+ c . count ++ ;
814+ } else {
815+ centroids . set ( n . namespace , { x : n . x ! , y : n . y ! , count : 1 } ) ;
816+ }
817+ }
818+ for ( const [ ns , c ] of centroids ) {
819+ c . x /= c . count ;
820+ c . y /= c . count ;
821+ centroids . set ( ns , c ) ;
822+ }
823+ const strength = 0.15 * alpha ;
824+ for ( const n of d3Nodes ) {
825+ if ( ! n . namespace || n . isExternal ) continue ;
826+ const c = centroids . get ( n . namespace ) ! ;
827+ n . vx ! += ( c . x - n . x ! ) * strength ;
828+ n . vy ! += ( c . y - n . y ! ) * strength ;
829+ }
830+ }
831+
749832 const simulation = d3 . forceSimulation ( d3Nodes )
750- . force ( 'link' , d3 . forceLink < D3Node , D3Link > ( d3Links ) . id ( d => d . id ) . distance ( 220 ) )
751- . force ( 'charge' , d3 . forceManyBody ( ) . strength ( - 800 ) )
752- . force ( 'center' , d3 . forceCenter ( WIDTH / 2 , HEIGHT / 2 ) )
753- . force ( 'collision' , d3 . forceCollide < D3Node > ( ) . radius ( d => nodeRadius ( d ) + 30 ) )
833+ . force ( 'link' , d3 . forceLink < D3Node , D3Link > ( d3Links ) . id ( d => d . id ) . distance ( 120 ) )
834+ . force ( 'charge' , d3 . forceManyBody ( ) . strength ( - 400 ) )
835+ . force ( 'center' , d3 . forceCenter ( svgWidth / 2 , HEIGHT / 2 ) )
836+ . force ( 'collision' , d3 . forceCollide < D3Node > ( ) . radius ( d => nodeRadius ( d ) + 20 ) )
837+ . force ( 'cluster' , forceNamespaceClustering as unknown as d3 . Force < D3Node , D3Link > )
754838 . on ( 'tick' , ( ) => {
755839 linkPaths . attr ( 'd' , d => {
756840 const s = d . source as D3Node ;
@@ -798,23 +882,24 @@ export default function ServiceMap({ entries }: ServiceMapProps) {
798882 return edgeData ? Math . min ( 1.5 + Math . log2 ( edgeData . connectionCount + 1 ) , 6 ) : 1 ;
799883 } ) ;
800884 }
801- } , [ graph , topologyChanged , topologyKey , showTooltip , hideTooltip ] ) ;
885+ } , [ graph , topologyChanged , topologyKey , showTooltip , hideTooltip , svgWidth ] ) ;
802886
803887 useEffect ( ( ) => {
804888 return ( ) => {
805889 simulationRef . current ?. stop ( ) ;
806890 } ;
807891 } , [ ] ) ;
808892
809- const handleCloseDetails = useCallback ( ( ) => setSelectedEdge ( null ) , [ ] ) ;
893+ const handleCloseDetails = useCallback ( ( ) => setSelectedEdgeKey ( null ) , [ ] ) ;
810894
811895 return (
812- < div style = { { position : 'relative' } } >
896+ < div ref = { containerRef } style = { { position : 'relative' , width : '100% ' } } >
813897 < svg
814898 ref = { svgRef }
815- width = { WIDTH }
899+ width = { svgWidth }
816900 height = { HEIGHT }
817901 style = { {
902+ display : 'block' ,
818903 border : '1px solid var(--cds-alias-object-border-color, #565656)' ,
819904 borderRadius : '4px' ,
820905 background : 'var(--cds-alias-object-container-background-dark, #17242b)' ,
0 commit comments