@@ -14,6 +14,7 @@ import {
1414 enhanceReport ,
1515 resolveLegendTextColor ,
1616 bindThemeChartRedraw ,
17+ bindSigmaHoverHighlight ,
1718 destroyBubbleCharts ,
1819 statefulElementIds ,
1920 stashStatefulDom ,
@@ -1067,7 +1068,8 @@ describe('showPopup sigma theme settings', () => {
10671068
10681069 // Minimal widget stubs so showPopup reaches the Sigma constructor: an
10691070 // empty graphlib-dot graph (the sugiyama layout then no-ops) and a Sigma
1070- // "constructor" recording its arguments.
1071+ // "constructor" recording its arguments and returning an instance stub
1072+ // (handlers by event, settings installed via setSetting, refresh count).
10711073 function stubSigmaStack ( ) {
10721074 const constructed = [ ] ;
10731075 window . graphlibDot = { read : ( ) => ( { nodes : ( ) => [ ] , edges : ( ) => [ ] } ) } ;
@@ -1081,7 +1083,19 @@ describe('showPopup sigma theme settings', () => {
10811083 }
10821084 } ;
10831085 window . Sigma = function ( graph , container , settings ) {
1084- constructed . push ( { graph, container, settings } ) ;
1086+ const entry = {
1087+ graph, settings,
1088+ instance : {
1089+ handlers : { } ,
1090+ installed : { } ,
1091+ scheduledRefreshes : 0 ,
1092+ on ( event , handler ) { this . handlers [ event ] = handler ; } ,
1093+ setSetting ( key , value ) { this . installed [ key ] = value ; } ,
1094+ scheduleRefresh ( ) { this . scheduledRefreshes += 1 ; }
1095+ }
1096+ } ;
1097+ constructed . push ( entry ) ;
1098+ return entry . instance ;
10851099 } ;
10861100 return constructed ;
10871101 }
@@ -1167,6 +1181,119 @@ describe('showPopup sigma theme settings', () => {
11671181 showPopup ( 'popup-classGraph' , 'graph-container-classGraph' , 'strict digraph G {}' ) ;
11681182 expect ( constructed [ 0 ] . settings ) . toBeUndefined ( ) ;
11691183 } ) ;
1184+
1185+ it ( 'binds neighbor and edge hover highlighting onto the sigma instance' , ( ) => {
1186+ installRadiosAndPopup ( 'light' ) ;
1187+ const constructed = stubSigmaStack ( ) ;
1188+ showPopup ( 'popup-classGraph' , 'graph-container-classGraph' , 'strict digraph G {}' ) ;
1189+ const instance = constructed [ 0 ] . instance ;
1190+ expect ( typeof instance . handlers . enterNode ) . toBe ( 'function' ) ;
1191+ expect ( typeof instance . handlers . leaveNode ) . toBe ( 'function' ) ;
1192+ expect ( typeof instance . installed . nodeReducer ) . toBe ( 'function' ) ;
1193+ expect ( typeof instance . installed . edgeReducer ) . toBe ( 'function' ) ;
1194+ } ) ;
1195+ } ) ;
1196+
1197+ describe ( 'bindSigmaHoverHighlight' , ( ) => {
1198+ // A stub graphology graph: edges eAB (A-B), eAC (A-C), eBD (B-D). D is a
1199+ // non-neighbor of A; eBD is not incident to A.
1200+ function stubHoverGraph ( ) {
1201+ return {
1202+ forEachNeighbor ( node , callback ) {
1203+ ( { A : [ 'B' , 'C' ] , B : [ 'A' , 'D' ] , C : [ 'A' ] , D : [ 'B' ] } ) [ node ] . forEach ( callback ) ;
1204+ } ,
1205+ edges ( node ) {
1206+ return ( { A : [ 'eAB' , 'eAC' ] , B : [ 'eAB' , 'eBD' ] , C : [ 'eAC' ] , D : [ 'eBD' ] } ) [ node ] ;
1207+ }
1208+ } ;
1209+ }
1210+
1211+ function stubSigmaInstance ( ) {
1212+ const instance = {
1213+ handlers : { } ,
1214+ installed : { } ,
1215+ scheduledRefreshes : 0 ,
1216+ on ( event , handler ) { instance . handlers [ event ] = handler ; } ,
1217+ setSetting ( key , value ) { instance . installed [ key ] = value ; } ,
1218+ scheduleRefresh ( ) { instance . scheduledRefreshes += 1 ; } ,
1219+ fire ( event , payload ) { instance . handlers [ event ] ( payload ) ; }
1220+ } ;
1221+ return instance ;
1222+ }
1223+
1224+ it ( 'flags neighbor nodes and thickens the incident edges of the hovered node' , ( ) => {
1225+ const instance = stubSigmaInstance ( ) ;
1226+ bindSigmaHoverHighlight ( instance , stubHoverGraph ( ) ) ;
1227+ instance . fire ( 'enterNode' , { node : 'A' } ) ;
1228+ expect ( instance . scheduledRefreshes ) . toBe ( 1 ) ;
1229+
1230+ const neighbor = { label : 'B' , size : 5 } ;
1231+ const stranger = { label : 'D' , size : 5 } ;
1232+ const hovered = { label : 'A' , size : 5 } ;
1233+ // Nodes across the hovered node's edges are re-rendered above labels…
1234+ expect ( instance . installed . nodeReducer ( 'B' , neighbor ) ) . toEqual ( { label : 'B' , size : 5 , highlighted : true } ) ;
1235+ // …while the hovered node itself (already emphasized by sigma's hover
1236+ // layer) and everything else keep their display data untouched.
1237+ expect ( instance . installed . nodeReducer ( 'D' , stranger ) ) . toBe ( stranger ) ;
1238+ expect ( instance . installed . nodeReducer ( 'A' , hovered ) ) . toBe ( hovered ) ;
1239+
1240+ const incident = { size : 1 , color : 'black' } ;
1241+ const unrelated = { size : 1 , color : 'black' } ;
1242+ // Edges directly connected to the hovered node thicken…
1243+ expect ( instance . installed . edgeReducer ( 'eAB' , incident ) ) . toEqual ( { size : 3 , color : 'black' } ) ;
1244+ expect ( instance . installed . edgeReducer ( 'eAC' , incident ) ) . toEqual ( { size : 3 , color : 'black' } ) ;
1245+ // …while unrelated edges keep their display data untouched.
1246+ expect ( instance . installed . edgeReducer ( 'eBD' , unrelated ) ) . toBe ( unrelated ) ;
1247+ } ) ;
1248+
1249+ it ( 'clears the highlighting when the node is left' , ( ) => {
1250+ const instance = stubSigmaInstance ( ) ;
1251+ bindSigmaHoverHighlight ( instance , stubHoverGraph ( ) ) ;
1252+ instance . fire ( 'enterNode' , { node : 'A' } ) ;
1253+ instance . fire ( 'leaveNode' , { } ) ;
1254+ expect ( instance . scheduledRefreshes ) . toBe ( 2 ) ;
1255+
1256+ const data = { label : 'B' , size : 5 } ;
1257+ expect ( instance . installed . nodeReducer ( 'B' , data ) ) . toBe ( data ) ;
1258+ expect ( instance . installed . edgeReducer ( 'eAB' , data ) ) . toBe ( data ) ;
1259+ } ) ;
1260+
1261+ it ( 're-populates cleanly when hopping straight from one node to another' , ( ) => {
1262+ const instance = stubSigmaInstance ( ) ;
1263+ bindSigmaHoverHighlight ( instance , stubHoverGraph ( ) ) ;
1264+ instance . fire ( 'enterNode' , { node : 'A' } ) ;
1265+ instance . fire ( 'enterNode' , { node : 'B' } ) ;
1266+
1267+ // Highlighting follows B: neighbors A and D, incident edges eAB and eBD.
1268+ const gone = { label : 'C' , size : 5 } ;
1269+ expect ( instance . installed . nodeReducer ( 'C' , gone ) ) . toBe ( gone ) ;
1270+ expect ( instance . installed . nodeReducer ( 'D' , { size : 5 } ) ) . toEqual ( { size : 5 , highlighted : true } ) ;
1271+ const dropped = { size : 1 , color : 'black' } ;
1272+ expect ( instance . installed . edgeReducer ( 'eAC' , dropped ) ) . toBe ( dropped ) ;
1273+ expect ( instance . installed . edgeReducer ( 'eBD' , { size : 1 , color : 'black' } ) ) . toEqual ( { size : 3 , color : 'black' } ) ;
1274+ } ) ;
1275+
1276+ it ( 'ignores leave events when nothing is highlighted' , ( ) => {
1277+ const instance = stubSigmaInstance ( ) ;
1278+ bindSigmaHoverHighlight ( instance , stubHoverGraph ( ) ) ;
1279+ instance . fire ( 'leaveNode' , { } ) ;
1280+ expect ( instance . scheduledRefreshes ) . toBe ( 0 ) ;
1281+ } ) ;
1282+
1283+ it ( 'ignores enter payloads without a node' , ( ) => {
1284+ const instance = stubSigmaInstance ( ) ;
1285+ bindSigmaHoverHighlight ( instance , stubHoverGraph ( ) ) ;
1286+ instance . fire ( 'enterNode' , { } ) ;
1287+ expect ( instance . scheduledRefreshes ) . toBe ( 0 ) ;
1288+ const data = { label : 'B' , size : 5 } ;
1289+ expect ( instance . installed . nodeReducer ( 'B' , data ) ) . toBe ( data ) ;
1290+ } ) ;
1291+
1292+ it ( 'does not throw for a missing sigma, event-less sigma or missing graph' , ( ) => {
1293+ expect ( ( ) => bindSigmaHoverHighlight ( null , stubHoverGraph ( ) ) ) . not . toThrow ( ) ;
1294+ expect ( ( ) => bindSigmaHoverHighlight ( { scheduleRefresh ( ) { } } , stubHoverGraph ( ) ) ) . not . toThrow ( ) ;
1295+ expect ( ( ) => bindSigmaHoverHighlight ( stubSigmaInstance ( ) , null ) ) . not . toThrow ( ) ;
1296+ } ) ;
11701297} ) ;
11711298
11721299describe ( 'bindPopupHandlers (template uses data attributes, no inline handlers)' , ( ) => {
0 commit comments