six-degrees 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (427) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/app-path-routes-manifest.json +1 -0
  3. package/.next/standalone/.next/build-manifest.json +3 -3
  4. package/.next/standalone/.next/prerender-manifest.json +35 -7
  5. package/.next/standalone/.next/required-server-files.json +6 -1
  6. package/.next/standalone/.next/routes-manifest.json +18 -1
  7. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  8. package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
  9. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  10. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  11. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  12. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  13. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  14. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  15. package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
  16. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  17. package/.next/standalone/.next/server/app/_not-found.rsc +11 -11
  18. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +11 -11
  19. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +10 -10
  20. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  21. package/.next/standalone/.next/server/app/api/admin-delete/route.js +1 -1
  22. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/admin-update/route.js +1 -1
  24. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/bridges/route.js +1 -1
  26. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/bulk-import/route.js +1 -1
  28. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  30. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/connections/route.js +1 -1
  32. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/delete-cluster/route.js +1 -1
  39. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/ingest/route.js +1 -1
  41. package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/network/route.js +1 -1
  43. package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/api/notifications/route.js +1 -1
  45. package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/api/outreach/route.js +3 -3
  47. package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/api/queue/route.js +1 -1
  49. package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
  50. package/.next/standalone/.next/server/app/api/scraper/route.js +1 -1
  51. package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
  52. package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
  53. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
  54. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
  55. package/.next/standalone/.next/server/app/api/setup-profile/route.js +1 -1
  56. package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
  57. package/.next/standalone/.next/server/app/api/unlock/route.js +1 -1
  58. package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/api/update-images/route.js +1 -1
  61. package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
  62. package/.next/standalone/.next/server/app/api/users/route.js +1 -1
  63. package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
  64. package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  66. package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
  67. package/.next/standalone/.next/server/app/import.html +1 -1
  68. package/.next/standalone/.next/server/app/import.rsc +13 -13
  69. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +13 -13
  70. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
  71. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +12 -12
  72. package/.next/standalone/.next/server/app/index.html +1 -1
  73. package/.next/standalone/.next/server/app/index.rsc +13 -13
  74. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +12 -12
  75. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +13 -13
  76. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  77. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  78. package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
  79. package/.next/standalone/.next/server/app/launch.html +2 -2
  80. package/.next/standalone/.next/server/app/launch.rsc +13 -13
  81. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +13 -13
  82. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
  83. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +12 -12
  84. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  85. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  86. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/paths.html +1 -1
  89. package/.next/standalone/.next/server/app/paths.rsc +13 -13
  90. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +13 -13
  91. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
  92. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +12 -12
  93. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  94. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  95. package/.next/standalone/.next/server/app/profile.html +1 -1
  96. package/.next/standalone/.next/server/app/profile.rsc +13 -13
  97. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +13 -13
  98. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
  99. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +12 -12
  100. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  101. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  102. package/.next/standalone/.next/server/app/queue.html +1 -1
  103. package/.next/standalone/.next/server/app/queue.rsc +13 -13
  104. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +13 -13
  105. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
  106. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +12 -12
  107. package/.next/standalone/.next/server/app/scores/page/app-paths-manifest.json +3 -0
  108. package/.next/standalone/.next/server/app/scores/page/build-manifest.json +20 -0
  109. package/.next/standalone/.next/server/app/scores/page/next-font-manifest.json +11 -0
  110. package/.next/standalone/.next/server/app/scores/page/react-loadable-manifest.json +1 -0
  111. package/.next/standalone/.next/server/app/scores/page/server-reference-manifest.json +4 -0
  112. package/.next/standalone/.next/server/app/scores/page.js +13 -0
  113. package/.next/standalone/.next/server/app/scores/page.js.map +5 -0
  114. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -0
  115. package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +3 -0
  116. package/.next/standalone/.next/server/app/scores.html +1 -0
  117. package/.next/standalone/.next/server/app/scores.meta +26 -0
  118. package/.next/standalone/.next/server/app/scores.rsc +24 -0
  119. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +24 -0
  120. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +4 -0
  121. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +28 -0
  122. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  123. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  124. package/.next/standalone/.next/server/app/settings.html +1 -1
  125. package/.next/standalone/.next/server/app/settings.rsc +13 -13
  126. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +13 -13
  127. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  128. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +12 -12
  129. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  130. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  131. package/.next/standalone/.next/server/app/setup.html +1 -1
  132. package/.next/standalone/.next/server/app/setup.rsc +13 -13
  133. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +13 -13
  134. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  135. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +12 -12
  136. package/.next/standalone/.next/server/app-paths-manifest.json +1 -0
  137. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-rnla2._.js +1 -1
  138. package/.next/standalone/.next/server/chunks/[root-of-the-server]__00la_y0._.js +1 -1
  139. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0i5hdu4._.js → [root-of-the-server]__03ydxxh._.js} +2 -2
  140. package/.next/standalone/.next/server/chunks/[root-of-the-server]__07t47cm._.js +1 -1
  141. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0bat8nx._.js +8 -0
  142. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cj5jqc._.js +1 -1
  143. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e0fei3._.js +1 -1
  144. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ljcioj._.js +1 -1
  145. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ljnyho._.js +1 -1
  146. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o5-tcg._.js +1 -1
  147. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0qrnous._.js +1 -1
  148. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t3v1a1._.js +1 -1
  149. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0uag-k5._.js +1 -1
  150. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0um1uey._.js +1 -1
  151. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wmexuc._.js +1 -1
  152. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xu34sz._.js +1 -1
  153. package/.next/standalone/.next/server/chunks/[root-of-the-server]__133knx1._.js +1 -1
  154. package/.next/standalone/.next/server/chunks/[root-of-the-server]__14ou7j5._.js +1 -1
  155. package/.next/standalone/.next/server/chunks/[root-of-the-server]__15-17hl._.js +1 -1
  156. package/.next/standalone/.next/server/chunks/[root-of-the-server]__16iu0hv._.js +1 -1
  157. package/.next/standalone/.next/server/chunks/[root-of-the-server]__19b7mig._.js +1 -1
  158. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1bbcwub._.js +2 -2
  159. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1eris29._.js +1 -1
  160. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1gjo_cq._.js +1 -1
  161. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1kbl21f._.js +1 -1
  162. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qcm08_._.js +1 -1
  163. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rphsdk._.js +1 -1
  164. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1w25q0m._.js +1 -1
  165. package/.next/standalone/.next/server/chunks/_02twlca._.js +10 -0
  166. package/.next/standalone/.next/server/chunks/_072jl0y._.js +1 -1
  167. package/.next/standalone/.next/server/chunks/_0lw8_i0._.js +1 -1
  168. package/.next/standalone/.next/server/chunks/_1_fynia._.js +8 -6
  169. package/.next/standalone/.next/server/chunks/_1fn30_l._.js +1 -1
  170. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  171. package/.next/standalone/.next/server/chunks/_1iwkh8t._.js +1 -1
  172. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +13 -8
  173. package/.next/standalone/.next/server/chunks/{lib_rpc_02ndsg2.js → lib_rpc_03i8xef.js} +1 -1
  174. package/.next/standalone/.next/server/chunks/{lib_rpc_08q5vp6.js → lib_rpc_08fc3rp.js} +1 -1
  175. package/.next/standalone/.next/server/chunks/{lib_rpc_00tx2sm.js → lib_rpc_08r2t_q.js} +1 -1
  176. package/.next/standalone/.next/server/chunks/{lib_rpc_02-qa4d.js → lib_rpc_0btpv1t.js} +1 -1
  177. package/.next/standalone/.next/server/chunks/lib_rpc_0c9d9ti.js +5 -5
  178. package/.next/standalone/.next/server/chunks/lib_rpc_0cm2ev3.js +3 -0
  179. package/.next/standalone/.next/server/chunks/lib_rpc_0iovswz.js +3 -0
  180. package/.next/standalone/.next/server/chunks/lib_rpc_0is8flp.js +3 -0
  181. package/.next/standalone/.next/server/chunks/lib_rpc_0v8ajtd.js +3 -0
  182. package/.next/standalone/.next/server/chunks/lib_rpc_0xgmvvt.js +3 -0
  183. package/.next/standalone/.next/server/chunks/lib_rpc_144mzbf.js +3 -0
  184. package/.next/standalone/.next/server/chunks/lib_rpc_1bggi0n.js +3 -0
  185. package/.next/standalone/.next/server/chunks/lib_rpc_1dkcu9f.js +3 -0
  186. package/.next/standalone/.next/server/chunks/lib_rpc_1o0jb6w.js +3 -0
  187. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__0an6blh._.js → [root-of-the-server]__0zzcbqg._.js} +2 -2
  188. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1194jps._.js +3 -0
  189. package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1z1j3ek._.js → [root-of-the-server]__19ip1qq._.js} +2 -2
  190. package/.next/standalone/.next/server/chunks/ssr/_01f38ty._.js +3 -0
  191. package/.next/standalone/.next/server/chunks/ssr/_06w_gl4._.js +3 -0
  192. package/.next/standalone/.next/server/chunks/ssr/_0hw_qnj._.js +3 -0
  193. package/.next/standalone/.next/server/chunks/ssr/_13kzrwv._.js +3 -0
  194. package/.next/standalone/.next/server/chunks/ssr/_15xuwkv._.js +3 -0
  195. package/.next/standalone/.next/server/chunks/ssr/_1cyxocj._.js +3 -0
  196. package/.next/standalone/.next/server/chunks/ssr/_1gpbbw9._.js +3 -0
  197. package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +3 -0
  198. package/.next/standalone/.next/server/chunks/ssr/_1vpfyd-._.js +3 -0
  199. package/.next/standalone/.next/server/chunks/ssr/_20w06i-._.js +3 -0
  200. package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_scores_page_actions_0x-w58n.js +3 -0
  201. package/.next/standalone/.next/server/chunks/ssr/app_components_settings_SectorPicker_1c-29qw.js +3 -0
  202. package/.next/standalone/.next/server/chunks/ssr/app_launch_page_19t0dyd.js +1 -1
  203. package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +11 -9
  204. package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +4 -0
  205. package/.next/standalone/.next/server/chunks/ssr/app_profile_page_0u3-l5y.js +1 -1
  206. package/.next/standalone/.next/server/chunks/ssr/app_queue_page_0c6znd6.js +5 -5
  207. package/.next/standalone/.next/server/chunks/ssr/app_scores_page_09wepvv.js +3 -0
  208. package/.next/standalone/.next/server/chunks/ssr/app_settings_page_1f8mjj_.js +1 -1
  209. package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +2 -2
  210. package/.next/standalone/.next/server/chunks/ssr/lib_1bn38gs._.js +3 -0
  211. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0x3i8za._.js +131 -0
  212. package/.next/standalone/.next/server/chunks/ssr/{node_modules_next_dist_1bunoni._.js → node_modules_next_dist_1v8aef8._.js} +2 -2
  213. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_client_components_server-async-storage_0t1dato.js +3 -0
  214. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  215. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  216. package/.next/standalone/.next/server/next-font-manifest.js +1 -1
  217. package/.next/standalone/.next/server/next-font-manifest.json +4 -0
  218. package/.next/standalone/.next/server/pages/404.html +1 -1
  219. package/.next/standalone/.next/server/pages/500.html +1 -1
  220. package/.next/standalone/.next/server/prefetch-hints.json +14 -0
  221. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  222. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  223. package/.next/standalone/.next/static/chunks/0ax_9zzboyv6d.js +44 -0
  224. package/.next/standalone/.next/static/chunks/0cuuo2jwc9h94.js +1 -0
  225. package/.next/standalone/.next/static/chunks/0l1568qff0efj.js +1 -0
  226. package/.next/standalone/.next/static/chunks/0muwt9bykzr-1.js +1 -0
  227. package/.next/standalone/.next/static/chunks/0w76iraw7ij3k.js +18 -0
  228. package/.next/standalone/.next/static/chunks/135n87ydxjrra.js +2 -0
  229. package/.next/standalone/.next/static/chunks/18xu0fbgo_qz-.js +1 -0
  230. package/.next/standalone/.next/static/chunks/28bu26ausqzmn.js +1 -0
  231. package/.next/standalone/.next/static/chunks/{40nvbgfn7-072.css → 2p6o90z0vgcwr.css} +1 -1
  232. package/.next/standalone/.next/static/chunks/2pnbm1z6j7xab.js +1 -0
  233. package/.next/standalone/.next/static/chunks/2xjho0lroiwlq.js +1 -0
  234. package/.next/standalone/.next/static/chunks/3e8zg6ctvvb8p.js +33 -0
  235. package/.next/standalone/.next/static/chunks/3qh78g2aqhprd.js +2 -0
  236. package/.next/standalone/CHANGELOG.md +286 -0
  237. package/.next/standalone/CONTRIBUTING.md +15 -4
  238. package/.next/standalone/README.md +50 -29
  239. package/.next/standalone/SECURITY.md +10 -0
  240. package/.next/standalone/app/api/company-scores/legacy/route.js +1 -1
  241. package/.next/standalone/app/api/company-scores/route.js +1 -1
  242. package/.next/standalone/app/api/ingest/route.js +23 -2
  243. package/.next/standalone/app/api/outreach/route.js +107 -44
  244. package/.next/standalone/app/api/scraper/route.js +146 -17
  245. package/.next/standalone/app/api/settings/sector-preview/route.js +3 -2
  246. package/.next/standalone/app/api/settings/sector-suggestions/route.js +1 -1
  247. package/.next/standalone/app/api/update-images/route.js +41 -7
  248. package/.next/standalone/app/avatars/[file]/route.js +6 -1
  249. package/.next/standalone/app/components/Avatar.js +5 -2
  250. package/.next/standalone/app/components/ChainView.js +659 -469
  251. package/.next/standalone/app/components/CompanyScores.js +16 -10
  252. package/.next/standalone/app/components/EmptyState.js +1 -1
  253. package/.next/standalone/app/components/FieldStep.js +94 -0
  254. package/.next/standalone/app/components/ForceGraph.js +183 -768
  255. package/.next/standalone/app/components/GridView.js +9 -7
  256. package/.next/standalone/app/components/LegacyScoresCard.js +1 -1
  257. package/.next/standalone/app/components/LinkedInLimits.js +22 -7
  258. package/.next/standalone/app/components/ListView.js +6 -4
  259. package/.next/standalone/app/components/MappingProgress.js +5 -6
  260. package/.next/standalone/app/components/OnboardingGate.js +1 -1
  261. package/.next/standalone/app/components/OrbitGraph.js +33 -1
  262. package/.next/standalone/app/components/OutlinkQuest.js +5 -4
  263. package/.next/standalone/app/components/PathsAnalyzer.js +1 -1
  264. package/.next/standalone/app/components/RingsView.js +9 -8
  265. package/.next/standalone/app/components/SeparationView.js +96 -14
  266. package/.next/standalone/app/components/Sidebar.js +366 -287
  267. package/.next/standalone/app/components/TheirCircle.js +300 -0
  268. package/.next/standalone/app/components/settings/SectorPicker.js +202 -0
  269. package/.next/standalone/app/components/settings/SectorSection.js +21 -195
  270. package/.next/standalone/app/components/settings/TierSection.js +99 -0
  271. package/.next/standalone/app/components/useRequests.js +12 -0
  272. package/.next/standalone/app/components/useScanner.js +13 -0
  273. package/.next/standalone/app/components/views.js +3 -5
  274. package/.next/standalone/app/globals.css +7 -0
  275. package/.next/standalone/app/launch/page.js +6 -3
  276. package/.next/standalone/app/page.js +183 -74
  277. package/.next/standalone/app/paths/page.js +20 -14
  278. package/.next/standalone/app/profile/page.js +3 -3
  279. package/.next/standalone/app/queue/page.js +56 -60
  280. package/.next/standalone/app/scores/page.js +72 -0
  281. package/.next/standalone/app/settings/page.js +15 -3
  282. package/.next/standalone/app/setup/page.js +423 -260
  283. package/.next/standalone/db/schema.js +2 -1
  284. package/.next/standalone/eslint.config.mjs +3 -3
  285. package/.next/standalone/install.sh +3 -3
  286. package/.next/standalone/lib/chain-layout.js +139 -0
  287. package/.next/standalone/lib/circle.js +169 -0
  288. package/.next/standalone/lib/companies.js +1 -1
  289. package/.next/standalone/lib/csv.js +21 -10
  290. package/.next/standalone/lib/data-export.js +2 -1
  291. package/.next/standalone/lib/data-folder.js +4 -2
  292. package/.next/standalone/lib/db-client.js +1 -0
  293. package/.next/standalone/lib/galaxy.js +12 -0
  294. package/.next/standalone/lib/ingest.js +27 -8
  295. package/.next/standalone/lib/legacy-offer.js +7 -6
  296. package/.next/standalone/lib/legacy-scores.js +2 -2
  297. package/.next/standalone/lib/linkedin-limits.js +30 -8
  298. package/.next/standalone/lib/photos.js +47 -0
  299. package/.next/standalone/lib/quest.js +8 -3
  300. package/.next/standalone/lib/rarity.js +80 -0
  301. package/.next/standalone/lib/reach.js +113 -0
  302. package/.next/standalone/lib/requests-client.js +113 -0
  303. package/.next/standalone/lib/rpc.js +26 -15
  304. package/.next/standalone/lib/scan-state.js +1 -0
  305. package/.next/standalone/lib/scanner-setup.js +20 -1
  306. package/.next/standalone/lib/score-guess.js +60 -0
  307. package/.next/standalone/lib/scoring.js +1761 -69
  308. package/.next/standalone/lib/scraper-client.js +235 -17
  309. package/.next/standalone/lib/sector-directory.js +1 -1
  310. package/.next/standalone/lib/sector-focus.js +17 -3
  311. package/.next/standalone/lib/separation.js +4 -2
  312. package/.next/standalone/lib/settings-client.js +21 -0
  313. package/.next/standalone/lib/settings-effects.js +19 -1
  314. package/.next/standalone/lib/settings.js +7 -2
  315. package/.next/standalone/next.config.mjs +25 -3
  316. package/.next/standalone/package-lock.json +2 -2
  317. package/.next/standalone/package.json +2 -2
  318. package/.next/standalone/public/demo-data.json +1 -1
  319. package/.next/standalone/scripts/build-app.mjs +11 -2
  320. package/.next/standalone/scripts/check-node.mjs +1 -1
  321. package/.next/standalone/scripts/gen-synthetic.mjs +1 -1
  322. package/.next/standalone/scripts/image_store.py +77 -21
  323. package/.next/standalone/scripts/scrape.py +305 -29
  324. package/.next/standalone/server.js +1 -1
  325. package/.next/static/chunks/0ax_9zzboyv6d.js +44 -0
  326. package/.next/static/chunks/0cuuo2jwc9h94.js +1 -0
  327. package/.next/static/chunks/0l1568qff0efj.js +1 -0
  328. package/.next/static/chunks/0muwt9bykzr-1.js +1 -0
  329. package/.next/static/chunks/0w76iraw7ij3k.js +18 -0
  330. package/.next/static/chunks/135n87ydxjrra.js +2 -0
  331. package/.next/static/chunks/18xu0fbgo_qz-.js +1 -0
  332. package/.next/static/chunks/28bu26ausqzmn.js +1 -0
  333. package/.next/static/chunks/{40nvbgfn7-072.css → 2p6o90z0vgcwr.css} +1 -1
  334. package/.next/static/chunks/2pnbm1z6j7xab.js +1 -0
  335. package/.next/static/chunks/2xjho0lroiwlq.js +1 -0
  336. package/.next/static/chunks/3e8zg6ctvvb8p.js +33 -0
  337. package/.next/static/chunks/3qh78g2aqhprd.js +2 -0
  338. package/CHANGELOG.md +286 -0
  339. package/README.md +50 -29
  340. package/SECURITY.md +10 -0
  341. package/db/schema.js +2 -1
  342. package/lib/chain-layout.js +139 -0
  343. package/lib/circle.js +169 -0
  344. package/lib/companies.js +1 -1
  345. package/lib/csv.js +21 -10
  346. package/lib/data-export.js +2 -1
  347. package/lib/data-folder.js +4 -2
  348. package/lib/db-client.js +1 -0
  349. package/lib/galaxy.js +12 -0
  350. package/lib/ingest.js +27 -8
  351. package/lib/legacy-offer.js +7 -6
  352. package/lib/legacy-scores.js +2 -2
  353. package/lib/linkedin-limits.js +30 -8
  354. package/lib/photos.js +47 -0
  355. package/lib/quest.js +8 -3
  356. package/lib/rarity.js +80 -0
  357. package/lib/reach.js +113 -0
  358. package/lib/requests-client.js +113 -0
  359. package/lib/rpc.js +26 -15
  360. package/lib/scan-state.js +1 -0
  361. package/lib/scanner-setup.js +20 -1
  362. package/lib/score-guess.js +60 -0
  363. package/lib/scoring.js +1761 -69
  364. package/lib/scraper-client.js +235 -17
  365. package/lib/sector-directory.js +1 -1
  366. package/lib/sector-focus.js +17 -3
  367. package/lib/separation.js +4 -2
  368. package/lib/settings-client.js +21 -0
  369. package/lib/settings-effects.js +19 -1
  370. package/lib/settings.js +7 -2
  371. package/package.json +2 -2
  372. package/public/demo-data.json +1 -1
  373. package/scripts/check-node.mjs +1 -1
  374. package/scripts/image_store.py +77 -21
  375. package/scripts/scrape.py +305 -29
  376. package/.next/standalone/.next/server/chunks/[root-of-the-server]__1t3rugi._.js +0 -3
  377. package/.next/standalone/.next/server/chunks/lib_rpc_0_8i3ft.js +0 -3
  378. package/.next/standalone/.next/server/chunks/lib_rpc_0huvw-3.js +0 -3
  379. package/.next/standalone/.next/server/chunks/lib_rpc_0ip2vdh.js +0 -3
  380. package/.next/standalone/.next/server/chunks/lib_rpc_0jzisgh.js +0 -3
  381. package/.next/standalone/.next/server/chunks/lib_rpc_0yi9xvd.js +0 -3
  382. package/.next/standalone/.next/server/chunks/lib_rpc_1jyhd0o.js +0 -3
  383. package/.next/standalone/.next/server/chunks/lib_rpc_1kn59-i.js +0 -3
  384. package/.next/standalone/.next/server/chunks/lib_rpc_1q2jkza.js +0 -3
  385. package/.next/standalone/.next/server/chunks/lib_rpc_1vpm1sk.js +0 -3
  386. package/.next/standalone/.next/server/chunks/lib_rpc_1x6p_31.js +0 -3
  387. package/.next/standalone/.next/server/chunks/ssr/_07u8byv._.js +0 -3
  388. package/.next/standalone/.next/server/chunks/ssr/_08r_mlf._.js +0 -3
  389. package/.next/standalone/.next/server/chunks/ssr/_0942v8n._.js +0 -3
  390. package/.next/standalone/.next/server/chunks/ssr/_0hl_t7r._.js +0 -3
  391. package/.next/standalone/.next/server/chunks/ssr/_0tb83dw._.js +0 -132
  392. package/.next/standalone/.next/server/chunks/ssr/_1-87bs1._.js +0 -3
  393. package/.next/standalone/.next/server/chunks/ssr/_17gui6a._.js +0 -3
  394. package/.next/standalone/.next/server/chunks/ssr/lib_0i8v-s3._.js +0 -3
  395. package/.next/standalone/.next/server/chunks/ssr/node_modules_16vay6l._.js +0 -3
  396. package/.next/standalone/.next/server/chunks/ssr/node_modules_1kciwn4._.js +0 -3
  397. package/.next/standalone/.next/static/chunks/06x3jiiz96m5t.js +0 -1
  398. package/.next/standalone/.next/static/chunks/08k1crid-05dt.js +0 -1
  399. package/.next/standalone/.next/static/chunks/0ngx9s6-zgshe.js +0 -33
  400. package/.next/standalone/.next/static/chunks/0q-_khndfxubv.js +0 -2
  401. package/.next/standalone/.next/static/chunks/1c79veqedvubt.js +0 -1
  402. package/.next/standalone/.next/static/chunks/1chp3nx45meg5.js +0 -44
  403. package/.next/standalone/.next/static/chunks/1irqj0lf-fu3d.js +0 -1
  404. package/.next/standalone/.next/static/chunks/1uadqylrod5_v.js +0 -1
  405. package/.next/standalone/.next/static/chunks/23u0zclgwpc4d.js +0 -1
  406. package/.next/standalone/.next/static/chunks/289ktj97di6zg.js +0 -1
  407. package/.next/standalone/.next/static/chunks/2b3lmf8e6uj8m.js +0 -16
  408. package/.next/standalone/.next/static/chunks/3npq5yd7c4dmn.js +0 -2
  409. package/.next/standalone/app/components/BridgeRing.js +0 -753
  410. package/.next/static/chunks/06x3jiiz96m5t.js +0 -1
  411. package/.next/static/chunks/08k1crid-05dt.js +0 -1
  412. package/.next/static/chunks/0ngx9s6-zgshe.js +0 -33
  413. package/.next/static/chunks/0q-_khndfxubv.js +0 -2
  414. package/.next/static/chunks/1c79veqedvubt.js +0 -1
  415. package/.next/static/chunks/1chp3nx45meg5.js +0 -44
  416. package/.next/static/chunks/1irqj0lf-fu3d.js +0 -1
  417. package/.next/static/chunks/1uadqylrod5_v.js +0 -1
  418. package/.next/static/chunks/23u0zclgwpc4d.js +0 -1
  419. package/.next/static/chunks/289ktj97di6zg.js +0 -1
  420. package/.next/static/chunks/2b3lmf8e6uj8m.js +0 -16
  421. package/.next/static/chunks/3npq5yd7c4dmn.js +0 -2
  422. /package/.next/standalone/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_buildManifest.js +0 -0
  423. /package/.next/standalone/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_clientMiddlewareManifest.js +0 -0
  424. /package/.next/standalone/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_ssgManifest.js +0 -0
  425. /package/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_buildManifest.js +0 -0
  426. /package/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_clientMiddlewareManifest.js +0 -0
  427. /package/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_ssgManifest.js +0 -0
@@ -2,6 +2,8 @@
2
2
 
3
3
  import { useEffect, useRef, useState } from 'react';
4
4
  import * as d3 from 'd3';
5
+ import { localPhoto } from '../../lib/photos';
6
+ import { recentre } from '../../lib/galaxy';
5
7
 
6
8
  // Connection fields are attacker-reachable: /api/ingest and /api/update-images
7
9
  // accept writes, and a page on any other site can POST to this app on localhost.
@@ -14,24 +16,18 @@ function esc(value) {
14
16
  ));
15
17
  }
16
18
 
17
- // Only paths this app itself produces, or ordinary remote images. Anything else
18
- // (javascript:, data:, an attribute-escaping payload) renders nothing.
19
- function safeImageUrl(url) {
20
- const value = String(url ?? '');
21
- return /^\/avatars\/[A-Za-z0-9_-]+\.(webp|png|jpe?g)$/.test(value) || /^https:\/\/[^"'\s<>]+$/.test(value)
22
- ? value
23
- : null;
24
- }
25
-
26
19
  // The id of the node at the centre, you. Not a connection's id (those come from
27
20
  // the database), and never shown.
28
21
  const CENTER_ID = '__center__';
29
22
 
30
23
  // Builds the avatar tooltip through the DOM rather than a string, so no value
31
- // can break out of the attribute it is written into.
24
+ // can break out of the attribute it is written into. Only a photo saved on this
25
+ // computer (lib/photos.js): not a link to LinkedIn, and never javascript:, data:
26
+ // or an attribute-escaping payload. Nodes carry nothing else, and this checks
27
+ // again.
32
28
  function renderPhoto(sel, d, size, borderColor) {
33
29
  sel.selectAll('*').remove();
34
- const src = safeImageUrl(d.profile_image_url);
30
+ const src = localPhoto(d.profile_image_url);
35
31
  if (!src) { sel.style('opacity', 0); return; }
36
32
  sel.append('img')
37
33
  .attr('src', src)
@@ -41,106 +37,127 @@ function renderPhoto(sel, d, size, borderColor) {
41
37
  .on('error', function () { this.parentElement.style.display = 'none'; });
42
38
  }
43
39
 
44
- export default function ForceGraph({ connections, degree2 = [], onSelect, tierColors, mode, focusNodeRef, userName }) {
40
+ // Reduce Motion, the Mac's accessibility setting. With it on, hovering and
41
+ // re-fitting change at once instead of easing, and the selection ring holds still.
42
+ function reducedMotion() {
43
+ return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
44
+ }
45
+
46
+ // The selection ring pulses on an element of its own, over the graph rather
47
+ // than inside the <svg>. Animating anything inside the <svg> repaints every dot
48
+ // on every frame, which at 30,000 people keeps a processor core busy for one
49
+ // ring. Opacity and scale on a separate element are left to the compositor.
50
+ const RING_CSS = `
51
+ @keyframes galaxy-ring-pulse {
52
+ 0%, 100% { opacity: 0.5; transform: scale(1); }
53
+ 50% { opacity: 1; transform: scale(1.12); }
54
+ }
55
+ .galaxy-ring-pulse { animation: galaxy-ring-pulse 1.8s ease-in-out infinite; }
56
+ @media (prefers-reduced-motion: reduce) {
57
+ .galaxy-ring-pulse { animation: none; opacity: 0.8; }
58
+ }`;
59
+
60
+ export default function ForceGraph({ connections, onSelect, tierColors, focusNodeRef, userName, selectedId = null }) {
45
61
  const svgRef = useRef(null);
46
- const zoomTransformRef = useRef(null); // Persist zoom across re-renders
47
- const prevModeRef = useRef(mode); // Track mode changes
48
- const zoomToClusterRef = useRef(null); // Function to zoom into a bridge cluster
49
- const zoomOutRef = useRef(null); // Function to zoom back to full view
50
- const [focusedCluster, setFocusedCluster] = useState(null);
51
- const [dimensions, setDimensions] = useState({ width: 800, height: 600 });
62
+ const ringRef = useRef(null);
63
+ const sceneRef = useRef(null); // The scene on screen: { resize, select }
64
+ // The view, kept across rebuilds: the zoom, and the size of box it was set for.
65
+ const viewRef = useRef({ transform: null, size: null });
66
+ const sizeRef = useRef(null);
67
+ const [dimensions, setDimensions] = useState(null);
52
68
 
53
69
  // The scene is rebuilt from scratch whenever its inputs change, so only real
54
70
  // changes should count. A parent re-rendering hands over a new onSelect every
55
71
  // time; reading it through a ref keeps the scene from resetting on every click.
56
72
  const onSelectRef = useRef(onSelect);
57
73
  useEffect(() => { onSelectRef.current = onSelect; });
58
- // The network view never uses degree2, so a new array there is not a change.
59
- const sceneDegree2 = mode === 'degrees' ? degree2 : null;
74
+ const selectedIdRef = useRef(selectedId);
60
75
 
61
76
  useEffect(() => {
62
77
  const container = svgRef.current?.parentElement;
63
78
  if (!container) return;
64
79
  const updateSize = () => {
65
80
  // Only publish a genuinely new size. A fresh object every observation is
66
- // never Object.is-equal, so React re-rendered and the whole scene was
67
- // rebuilt on every callback — including the sub-pixel churn a scrollbar
68
- // appearing and disappearing produces.
81
+ // never Object.is-equal, so React re-rendered on every callback —
82
+ // including the sub-pixel churn a scrollbar appearing and disappearing
83
+ // produces.
69
84
  const width = Math.round(container.clientWidth);
70
85
  const height = Math.round(container.clientHeight);
86
+ sizeRef.current = { width, height };
71
87
  setDimensions((prev) =>
72
- prev.width === width && prev.height === height ? prev : { width, height }
88
+ prev && prev.width === width && prev.height === height ? prev : { width, height }
73
89
  );
74
90
  };
75
91
  updateSize();
76
- // A resize rebuilds the whole scene, so wait for it to settle. Dragging a
77
- // window edge, or anything that flickers the size, otherwise rebuilds on every
78
- // frame — and a rebuild that itself changes the size never stops (TRAPS §29).
79
- let timer = null;
80
- const ro = new ResizeObserver(() => {
81
- clearTimeout(timer);
82
- timer = setTimeout(updateSize, 150);
83
- });
92
+ // A resize only re-fits the view (below), so it no longer waits to settle.
93
+ // It used to rebuild the whole scene, and a rebuild that itself changed the
94
+ // size never stopped (TRAPS §29).
95
+ const ro = new ResizeObserver(updateSize);
84
96
  ro.observe(container);
85
- return () => { ro.disconnect(); clearTimeout(timer); };
97
+ return () => ro.disconnect();
86
98
  }, []);
87
99
 
88
- // Listen for cluster focus events from D3
100
+ // Selection moves the ring; it never rebuilds the scene.
89
101
  useEffect(() => {
90
- const handler = (e) => setFocusedCluster(e.detail);
91
- window.addEventListener('cluster-focus', handler);
92
- return () => window.removeEventListener('cluster-focus', handler);
93
- }, []);
102
+ selectedIdRef.current = selectedId;
103
+ sceneRef.current?.select(selectedId);
104
+ }, [selectedId]);
105
+
106
+ // Nor does a new size. Opening the side panel or the Filter panel used to
107
+ // rebuild the Galaxy: the layout started over, the selection ring was
108
+ // erased, and at 30,000 people the page stalled. Now the view slides over,
109
+ // at the same zoom, so what was in the middle stays in the middle.
110
+ useEffect(() => {
111
+ if (dimensions) sceneRef.current?.resize(dimensions);
112
+ }, [dimensions]);
94
113
 
114
+ const measured = dimensions !== null;
95
115
  useEffect(() => {
96
- if (!svgRef.current || !connections.length) return;
97
- let simulation = null;
98
- const { width, height } = dimensions;
116
+ if (!measured || !svgRef.current || !connections.length) return;
99
117
  const svg = d3.select(svgRef.current);
100
118
  svg.selectAll('*').remove();
101
119
 
102
- // Did the mode change? If so, reset zoom. Otherwise preserve it.
103
- const modeChanged = mode !== prevModeRef.current;
104
- prevModeRef.current = mode;
105
- const savedTransform = modeChanged ? null : zoomTransformRef.current;
120
+ // The view as it was, moved for any change of size since it was set.
121
+ const size = sizeRef.current;
122
+ const { transform, size: setFor } = viewRef.current;
123
+ const saved = transform ? recentre(transform, setFor, size) : null;
106
124
 
107
125
  const select = (d) => onSelectRef.current?.(d);
108
- if (mode === 'degrees') {
109
- simulation = renderDegreesMode(svg, width, height, connections, sceneDegree2 || [], select, tierColors, savedTransform, zoomTransformRef, userName, zoomToClusterRef, zoomOutRef);
110
- } else {
111
- simulation = renderNetworkMode(svg, width, height, connections, select, tierColors, focusNodeRef, savedTransform, zoomTransformRef, userName);
112
- }
126
+ const scene = renderNetworkMode(svg, ringRef.current, size, connections, select, tierColors, focusNodeRef, saved, viewRef, userName);
127
+ sceneRef.current = scene;
128
+ scene.select(selectedIdRef.current);
113
129
 
114
130
  return () => {
115
131
  // Stop the force simulation this render started.
116
132
  //
117
- // It used to be left running. Every rebuild — and the scene rebuilt on
118
- // any resize — added another simulation still ticking over the same
119
- // nodes, so they fought each other and the graph shook. Refreshing made
120
- // it worse because nothing ever stopped the old ones.
121
- simulation?.stop();
133
+ // It used to be left running. Every rebuild — and the scene used to
134
+ // rebuild on any resize — added another simulation still ticking over
135
+ // the same nodes, so they fought each other and the graph shook.
136
+ // Refreshing made it worse because nothing ever stopped the old ones.
137
+ scene.simulation.stop();
138
+ // And any glide or fly-to still under way, which would otherwise go on
139
+ // moving the next scene's view. viewRef already holds where it was going.
140
+ svg.interrupt();
141
+ sceneRef.current = null;
122
142
  d3.selectAll('.graph-tooltip').remove();
123
143
  };
124
144
  // focusNodeRef and userName are read at build time on purpose; see above for
125
- // why onSelect is not a dependency.
145
+ // why onSelect is not a dependency. The size is read through sizeRef: a new
146
+ // size re-fits the view rather than rebuilding.
126
147
  // eslint-disable-next-line react-hooks/exhaustive-deps
127
- }, [connections, sceneDegree2, dimensions, tierColors, mode]);
148
+ }, [connections, tierColors, measured]);
128
149
 
129
150
  return (
130
151
  <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
131
- <svg ref={svgRef} width={dimensions.width} height={dimensions.height} style={{ background: '#0a0a1a' }} />
132
- {/* Back to all clusters button — shows when zoomed into a cluster */}
133
- {focusedCluster && mode === 'degrees' && (
134
- <button onClick={() => {
135
- setFocusedCluster(null);
136
- if (zoomOutRef.current) zoomOutRef.current();
137
- }} style={{
138
- position: 'absolute', top: 16, left: 16, zIndex: 20,
139
- padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
140
- background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: 12, fontWeight: 600,
141
- backdropFilter: 'blur(8px)',
142
- }}>← All Bridges</button>
143
- )}
152
+ <style>{RING_CSS}</style>
153
+ <svg ref={svgRef} width={dimensions?.width ?? 800} height={dimensions?.height ?? 600} style={{ background: '#0a0a1a' }} />
154
+ {/* The selection ring. The scene places it over the selected dot. */}
155
+ <div ref={ringRef} className="galaxy-ring" style={{
156
+ position: 'absolute', left: 0, top: 0, display: 'none',
157
+ pointerEvents: 'none', transformOrigin: '0 0',
158
+ }}>
159
+ <div className="galaxy-ring-pulse" style={{ boxSizing: 'border-box', border: '2.5px solid', borderRadius: '50%' }} />
160
+ </div>
144
161
  <div style={{ position: 'absolute', bottom: 20, left: 20, display: 'flex', gap: 12, fontSize: 11, color: '#888' }}>
145
162
  {Object.entries(tierColors).map(([tier, color]) => (
146
163
  <span key={tier} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
@@ -148,17 +165,6 @@ export default function ForceGraph({ connections, degree2 = [], onSelect, tierCo
148
165
  {tier}
149
166
  </span>
150
167
  ))}
151
- {mode === 'degrees' && (<>
152
- <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
153
- <span style={{ width: 8, height: 8, borderRadius: 0, border: '2px solid #00ff88', background: 'transparent', display: 'inline-block' }} />
154
- Mutual
155
- </span>
156
- <span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
157
- <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#00ff88', display: 'inline-block' }} />
158
- Catalyst
159
- </span>
160
- </>
161
- )}
162
168
  </div>
163
169
  </div>
164
170
  );
@@ -183,23 +189,35 @@ function createTooltip() {
183
189
  .style('border', '1px solid rgba(255,255,255,0.2)').style('max-width', '280px');
184
190
  }
185
191
 
186
- function renderNetworkMode(svg, width, height, connections, onSelect, tierColors, focusNodeRef, savedTransform, zoomTransformRef, userName) {
192
+ // A hovered dot eases up to 1.5 times its size and back, rather than jumping.
193
+ // The transition is named, so it never cuts off another one on the same dot.
194
+ function easeRadius(el, r) {
195
+ const dot = d3.select(el);
196
+ if (reducedMotion()) dot.interrupt('hover').attr('r', r);
197
+ else dot.transition('hover').duration(150).ease(d3.easeCubicOut).attr('r', r);
198
+ }
199
+
200
+ // Graph coordinates put you at 0,0, whatever the size of the box; the zoom
201
+ // transform places that in the box. So a new size only moves the view, and a
202
+ // rebuild can start from the view as it was.
203
+ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, focusNodeRef, savedTransform, viewRef, userName) {
187
204
  const centerNode = {
188
205
  id: CENTER_ID, name: userName || 'You', tier: 'center', degree: 0,
189
- power_score: 10, fx: width / 2, fy: height / 2,
206
+ power_score: 10, fx: 0, fy: 0,
190
207
  };
191
208
 
192
209
  const nodes = [centerNode, ...connections.map(c => ({
193
210
  id: c.id, name: c.name, tier: c.tier, degree: 1,
194
211
  power_score: parseFloat(c.power_score) || 1,
195
212
  company: c.company, role: c.role, headline: c.headline,
196
- profile_url: c.profile_url, profile_image_url: c.profile_image_url,
213
+ profile_url: c.profile_url, profile_image_url: localPhoto(c.profile_image_url),
197
214
  connected_date: c.connected_date,
198
215
  seniority_score: c.seniority_score, company_prestige_score: c.company_prestige_score,
199
216
  influence_signals: c.influence_signals,
200
217
  is_catalyst: c.is_catalyst, catalyst_score: c.catalyst_score,
201
218
  circle_power: c.circle_power, circle_s_count: c.circle_s_count, circle_a_count: c.circle_a_count,
202
219
  }))];
220
+ const nodeById = new Map(nodes.map(n => [n.id, n]));
203
221
 
204
222
  const links = connections.map(c => ({ source: CENTER_ID, target: c.id }));
205
223
 
@@ -212,53 +230,100 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
212
230
  return Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
213
231
  };
214
232
 
233
+ // A phone. Judged by the window, as the page judges it, not by the graph's
234
+ // own box: that narrowed whenever a panel opened on a laptop.
235
+ const isMobileGraph = window.innerWidth < 768;
236
+
215
237
  const g = svg.append('g');
238
+ // Where the view is going while a transition takes it there. A resize part-way
239
+ // through re-fits the destination, and a rebuild starts from it, rather than
240
+ // from a point along the way.
241
+ let heading = null;
216
242
  const zoomBehavior = d3.zoom().scaleExtent([0.3, 4]).on('zoom', (e) => {
217
243
  g.attr('transform', e.transform);
218
- if (zoomTransformRef) zoomTransformRef.current = e.transform;
244
+ viewRef.current = { transform: heading ?? e.transform, size: box };
245
+ placeRing();
219
246
  });
220
247
  svg.call(zoomBehavior);
221
248
 
222
- // Restore previous zoom position if available (filter changes, sidebar toggles)
249
+ const moveView = (to, duration) => {
250
+ if (reducedMotion()) {
251
+ heading = null;
252
+ svg.interrupt().call(zoomBehavior.transform, to);
253
+ return;
254
+ }
255
+ heading = to;
256
+ viewRef.current = { transform: to, size: box };
257
+ // Stopped before its first frame, by a wheel or a click at that moment, a
258
+ // slide reports "cancel" rather than "interrupt". Missing that left heading
259
+ // set, and every zoom after it was saved as this destination. A scroll
260
+ // already under way doesn't stop a slide, which lands here all the same.
261
+ svg.transition().duration(duration).call(zoomBehavior.transform, to)
262
+ .on('end interrupt cancel', () => { if (heading === to) heading = null; });
263
+ };
264
+
265
+ // The selection ring, tied to the selected person's dot. It is drawn again
266
+ // after any rebuild, and hidden while they are filtered out.
267
+ const pulse = ring.firstChild;
268
+ let ringNode = null;
269
+ const placeRing = () => {
270
+ if (!ringNode) return;
271
+ const t = d3.zoomTransform(svg.node());
272
+ ring.style.transform = `translate(${t.applyX(ringNode.x)}px, ${t.applyY(ringNode.y)}px) scale(${t.k})`;
273
+ };
274
+ const select = (id) => {
275
+ ringNode = id != null && id !== CENTER_ID ? nodeById.get(id) ?? null : null;
276
+ ring.style.display = ringNode ? 'block' : 'none';
277
+ if (!ringNode) return;
278
+ // The ring the <svg> used to draw: 8 past the dot, 2.5 wide.
279
+ const r = nodeRadius(ringNode) + 8;
280
+ pulse.style.width = pulse.style.height = `${2 * r + 2.5}px`;
281
+ pulse.style.margin = `${-(r + 1.25)}px`;
282
+ pulse.style.borderColor = tierColors[ringNode.tier] || '#FFD700';
283
+ placeRing();
284
+ };
285
+
286
+ // A new size: the view slides by half the change, at the same zoom.
287
+ const resize = (next) => {
288
+ const t = recentre(heading ?? d3.zoomTransform(svg.node()), box, next);
289
+ box = next;
290
+ moveView(d3.zoomIdentity.translate(t.x, t.y).scale(t.k), 250);
291
+ };
292
+
293
+ // Restore the previous view if there is one (filter changes); otherwise put
294
+ // you in the middle of the box. A phone starts zoomed out to fit, since the
295
+ // outer tiers reach past its edges.
223
296
  if (savedTransform) {
224
- svg.call(zoomBehavior.transform, savedTransform);
297
+ svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(savedTransform.x, savedTransform.y).scale(savedTransform.k));
298
+ } else {
299
+ const maxTierR = 520;
300
+ const fitScale = isMobileGraph && box.width > 0 && box.height > 0
301
+ ? Math.min(box.width / (maxTierR * 2.4), box.height / (maxTierR * 2.4)) : 1;
302
+ svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(box.width / 2, box.height / 2).scale(fitScale));
225
303
  }
226
304
 
227
305
  // Expose focusNode for search — zooms to a specific node
228
306
  if (focusNodeRef) {
229
307
  focusNodeRef.current = (nodeId) => {
230
- const target = nodes.find(n => n.id === nodeId);
308
+ const target = nodeById.get(nodeId);
231
309
  if (!target) return;
232
310
  const scale = 2.5;
233
- const tx = width / 2 - target.x * scale;
234
- const ty = height / 2 - target.y * scale;
235
- svg.transition().duration(750).call(
236
- zoomBehavior.transform,
237
- d3.zoomIdentity.translate(tx, ty).scale(scale)
238
- );
239
- // Add selection glow
240
- g.selectAll('.selected-glow').remove();
241
- g.append('circle')
242
- .attr('class', 'selected-glow')
243
- .attr('cx', target.x).attr('cy', target.y).attr('r', nodeRadius(target) + 8)
244
- .attr('fill', 'none').attr('stroke', tierColors[target.tier] || '#FFD700')
245
- .attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
246
- .style('animation', 'selectedPulse 1.5s ease-in-out infinite');
311
+ moveView(d3.zoomIdentity.translate(box.width / 2 - target.x * scale, box.height / 2 - target.y * scale).scale(scale), 750);
312
+ select(nodeId);
247
313
  };
248
314
  }
249
315
 
250
316
  // On mobile: pre-compute static positions (no simulation jitter/reset on resize)
251
- const isMobileGraph = width < 768;
252
317
  if (isMobileGraph) {
253
318
  const tierGroups = {};
254
319
  nodes.forEach(n => { const t = n.tier || 'D'; if (!tierGroups[t]) tierGroups[t] = []; tierGroups[t].push(n); });
255
320
  Object.entries(tierGroups).forEach(([tier, group]) => {
256
321
  group.forEach((n, i) => {
257
- if (n.id === CENTER_ID) { n.x = width / 2; n.y = height / 2; n.fx = n.x; n.fy = n.y; return; }
322
+ if (n.id === CENTER_ID) { n.x = 0; n.y = 0; n.fx = n.x; n.fy = n.y; return; }
258
323
  const r = tierRadius(tier);
259
324
  const angle = (i / group.length) * Math.PI * 2 - Math.PI / 2;
260
- n.x = width / 2 + r * Math.cos(angle);
261
- n.y = height / 2 + r * Math.sin(angle);
325
+ n.x = r * Math.cos(angle);
326
+ n.y = r * Math.sin(angle);
262
327
  n.fx = n.x; n.fy = n.y; // Fixed positions — no physics
263
328
  });
264
329
  });
@@ -267,12 +332,15 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
267
332
  const simulation = d3.forceSimulation(nodes)
268
333
  .force('link', d3.forceLink(links).id(d => d.id).distance(d => tierRadius(d.target.tier || 'D')).strength(isMobileGraph ? 0 : 0.1))
269
334
  .force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
270
- .force('center', isMobileGraph ? null : d3.forceCenter(width / 2, height / 2))
335
+ .force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
271
336
  .force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
272
- .force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier), width / 2, height / 2).strength(0.3));
337
+ .force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier), 0, 0).strength(0.3));
273
338
 
339
+ // forceLink has already swapped each link's ids for the nodes themselves, so
340
+ // the colour is one step away. It used to search every node for every link:
341
+ // about 1.8 s at 30,000 people.
274
342
  const link = g.append('g').selectAll('line').data(links).join('line')
275
- .attr('stroke', d => { const t = nodes.find(n => n.id === (d.target.id || d.target)); return tierColors[t?.tier] || '#333'; })
343
+ .attr('stroke', d => tierColors[d.target.tier] || '#333')
276
344
  .attr('stroke-opacity', 0.15).attr('stroke-width', 0.5);
277
345
 
278
346
  // Catalyst outer glow rings (rendered behind the nodes)
@@ -309,16 +377,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
309
377
  .style('cursor', 'pointer')
310
378
  .on('click', (event, d) => {
311
379
  if (d.id === CENTER_ID) return;
312
- // Remove previous selection glow
313
- g.selectAll('.selected-glow').remove();
314
- // Add breathing glow to selected node
315
- const r = nodeRadius(d);
316
- g.append('circle')
317
- .attr('class', 'selected-glow')
318
- .attr('cx', d.x).attr('cy', d.y).attr('r', r + 8)
319
- .attr('fill', 'none').attr('stroke', tierColors[d.tier] || '#FFD700')
320
- .attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
321
- .style('animation', 'selectedPulse 1.5s ease-in-out infinite');
380
+ select(d.id);
322
381
  onSelect(d);
323
382
  });
324
383
 
@@ -331,7 +390,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
331
390
  .style('z-index', 1001).style('transition', 'opacity 0.15s');
332
391
 
333
392
  node.on('mouseover', function (event, d) {
334
- d3.select(this).attr('r', nodeRadius(d) * 1.5);
393
+ easeRadius(this, nodeRadius(d) * 1.5);
335
394
  if (d.profile_image_url && d.id !== CENTER_ID) {
336
395
  const size = Math.max(48, nodeRadius(d) * 5);
337
396
  photoTooltip.style('opacity', 1);
@@ -358,7 +417,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
358
417
  tooltip.style('left', (event.clientX + 12) + 'px').style('top', (event.clientY - 10) + 'px');
359
418
  }
360
419
  }).on('mouseout', function (event, d) {
361
- d3.select(this).attr('r', nodeRadius(d));
420
+ easeRadius(this, nodeRadius(d));
362
421
  tooltip.style('opacity', 0);
363
422
  photoTooltip.style('opacity', 0);
364
423
  });
@@ -386,654 +445,10 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
386
445
  node.attr('cx', d => d.x).attr('cy', d => d.y);
387
446
  catalystRings.attr('cx', d => d.x).attr('cy', d => d.y);
388
447
  labels.attr('x', d => d.x).attr('y', d => d.y);
389
- // Move selection glow with the node
390
- g.selectAll('.selected-glow').each(function() {
391
- const el = d3.select(this);
392
- const match = nodes.find(n => n.x && Math.abs(n.x - parseFloat(el.attr('cx'))) < 30);
393
- if (match) { el.attr('cx', match.x).attr('cy', match.y); }
394
- });
395
-
396
- });
397
-
398
- // Auto-fit zoom ONLY on small screens (mobile) where content overflows
399
- if (!savedTransform && width > 0 && width < 768) {
400
- const maxTierR = 520;
401
- const fitScale = Math.min(width / (maxTierR * 2.4), height / (maxTierR * 2.4));
402
- const initialTransform = d3.zoomIdentity.translate(width / 2, height / 2).scale(fitScale).translate(-width / 2, -height / 2);
403
- svg.call(zoomBehavior.transform, initialTransform);
404
- if (zoomTransformRef) zoomTransformRef.current = initialTransform;
405
- }
406
-
407
- return simulation;
408
- }
409
-
410
- function renderDegreesMode(svg, width, height, allD1, degree2, onSelect, tierColors, savedTransform, zoomTransformRef, userName, zoomToClusterRef, zoomOutRef) {
411
- const d1Urls = new Set(allD1.map(c => c.profile_url));
412
- const bridgeIds = [...new Set(degree2.map(d => d.source_connection_id).filter(Boolean))];
413
- const bridges = allD1.filter(c => bridgeIds.includes(c.id));
414
- const numBridges = Math.max(bridges.length, 1);
415
- const cx = width / 2, cy = height / 2;
416
-
417
- // Per-tier orbit distance from the centre (you): S closest, A further, etc.
418
- const bridgeTierOrbit = { S: 350, A: 600, B: 820, C: 1000, D: 1150 };
419
- const tierRing = { S: 40, A: 70, B: 100, C: 125, D: 145 };
420
- const tierOrder = ['S', 'A', 'B', 'C', 'D'];
421
-
422
- // --- Build nodes ---
423
- const centerNode = {
424
- id: CENTER_ID, name: userName || 'You', tier: 'center', degree: 0,
425
- power_score: 10, nodeType: 'center', fx: cx, fy: cy,
426
- };
427
-
428
- // Group bridges by tier so same-tier bridges are spaced around their ring
429
- const tierCounts = {};
430
- const tierIndex = {};
431
- bridges.forEach(c => {
432
- const t = c.tier || 'D';
433
- tierCounts[t] = (tierCounts[t] || 0) + 1;
434
- tierIndex[c.id] = (tierIndex[c.id] || 0);
435
- });
436
- const tierCounter = {};
437
- const bridgeNodes = bridges.map((c) => {
438
- const t = c.tier || 'D';
439
- const count = tierCounts[t] || 1;
440
- const idx = tierCounter[t] || 0;
441
- tierCounter[t] = idx + 1;
442
- const orbitR = bridgeTierOrbit[t] || 600;
443
- const angleOffset = { S: 0, A: Math.PI / 7, B: Math.PI / 4, C: Math.PI / 3, D: Math.PI / 2.5 };
444
- const angle = (2 * Math.PI * idx) / count + (angleOffset[t] || 0);
445
- return {
446
- id: c.id, name: c.name, tier: c.tier, degree: 1, nodeType: 'bridge',
447
- power_score: parseFloat(c.power_score) || 1,
448
- company: c.company, role: c.role, headline: c.headline,
449
- profile_url: c.profile_url, profile_image_url: c.profile_image_url,
450
- seniority_score: c.seniority_score,
451
- company_prestige_score: c.company_prestige_score,
452
- is_catalyst: c.is_catalyst, catalyst_score: c.catalyst_score,
453
- circle_power: c.circle_power, circle_s_count: c.circle_s_count, circle_a_count: c.circle_a_count,
454
- unlocked_from_bridge_id: c.unlocked_from_bridge_id,
455
- unlocked_from_name: c.unlocked_from_name,
456
- _orbitRadius: orbitR,
457
- x: cx + Math.cos(angle) * orbitR,
458
- y: cy + Math.sin(angle) * orbitR,
459
- };
460
- });
461
-
462
- const d2Nodes = [];
463
- bridgeNodes.forEach(bridge => {
464
- const myD2 = degree2.filter(c => c.source_connection_id === bridge.id);
465
- const byTier = {};
466
- tierOrder.forEach(t => { byTier[t] = []; });
467
- myD2.forEach(c => { (byTier[c.tier] || byTier['D']).push(c); });
468
- tierOrder.forEach(tier => {
469
- const group = byTier[tier];
470
- const ring = tierRing[tier];
471
- group.forEach((c, i) => {
472
- const angle = (2 * Math.PI * i) / Math.max(group.length, 1) - Math.PI / 2;
473
- const isMutual = d1Urls.has(c.profile_url);
474
- d2Nodes.push({
475
- id: c.id, name: c.name, tier: c.tier, degree: 2, nodeType: 'degree2',
476
- power_score: parseFloat(c.power_score) || 1,
477
- company: c.company, role: c.role, headline: c.headline,
478
- profile_url: c.profile_url, profile_image_url: c.profile_image_url,
479
- source_connection_id: c.source_connection_id,
480
- seniority_score: c.seniority_score, company_prestige_score: c.company_prestige_score,
481
- isMutual, unlock_status: c.unlock_status,
482
- x: bridge.x + Math.cos(angle) * ring,
483
- y: bridge.y + Math.sin(angle) * ring,
484
- });
485
- });
486
- });
487
- });
488
-
489
- const nodes = [centerNode, ...bridgeNodes, ...d2Nodes];
490
- const links = [
491
- ...bridgeNodes.map(b => ({ source: CENTER_ID, target: b.id, linkType: 'bridge' })),
492
- ...d2Nodes.map(d => ({ source: d.source_connection_id, target: d.id, linkType: 'degree2' })),
493
- ];
494
-
495
- // Index for fast lookup in forces
496
- const bridgeById = {};
497
- bridgeNodes.forEach(b => { bridgeById[b.id] = b; });
498
-
499
- const nodeRadius = (d) => {
500
- if (d.nodeType === 'center') return 24;
501
- if (d.nodeType === 'bridge') return 16;
502
- return Math.max(4, Math.min(10, (d.power_score || 1) * 1.1));
503
- };
504
-
505
- // --- SVG setup ---
506
- const g = svg.append('g');
507
- const zoomBehavior = d3.zoom().scaleExtent([0.15, 5]).on('zoom', (e) => {
508
- g.attr('transform', e.transform);
509
- if (zoomTransformRef) zoomTransformRef.current = e.transform;
448
+ placeRing();
510
449
  });
511
- svg.call(zoomBehavior);
512
450
 
513
- // Restore previous zoom position if available
514
- if (savedTransform) {
515
- svg.call(zoomBehavior.transform, savedTransform);
516
- }
517
-
518
- // Expose zoom-to-cluster: smoothly zoom into a bridge node's position
519
- if (zoomToClusterRef) {
520
- zoomToClusterRef.current = (bridgeNode) => {
521
- const scale = 2.5;
522
- const tx = width / 2 - bridgeNode.x * scale;
523
- const ty = height / 2 - bridgeNode.y * scale;
524
- svg.transition().duration(750).call(
525
- zoomBehavior.transform,
526
- d3.zoomIdentity.translate(tx, ty).scale(scale)
527
- );
528
- };
529
- }
530
-
531
- // Expose zoom-out: return to the auto-fit view
532
- if (zoomOutRef) {
533
- zoomOutRef.current = () => {
534
- const maxOrbit = bridgeNodes.length > 0 ? Math.max(...bridgeNodes.map(b => b._orbitRadius || 500)) : 500;
535
- const fitR = maxOrbit + 200;
536
- const fitScale = Math.min(width / (fitR * 2.2), height / (fitR * 2.2), 0.85);
537
- svg.transition().duration(750).call(
538
- zoomBehavior.transform,
539
- d3.zoomIdentity.translate(width / 2, height / 2).scale(fitScale).translate(-cx, -cy)
540
- );
541
- };
542
- }
543
-
544
- // --- Defs: gradients, glows, filters ---
545
- const defs = svg.append('defs');
546
- const baseClusterColors = ['#FFD700', '#FF6B35', '#9B59B6', '#3498DB'];
547
- let colorIdx = 0;
548
- const CATALYST_COLOR = '#00ff88';
549
- bridgeNodes.forEach((bridge, bi) => {
550
- const color = bridge.is_catalyst ? CATALYST_COLOR : baseClusterColors[colorIdx++ % baseClusterColors.length];
551
- bridge._clusterColor = color;
552
- const grad = defs.append('radialGradient').attr('id', `nebula-${bi}`);
553
- grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.08);
554
- grad.append('stop').attr('offset', '40%').attr('stop-color', color).attr('stop-opacity', 0.03);
555
- grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0);
556
- });
557
- const glowFilter = defs.append('filter').attr('id', 'glow').attr('x', '-50%').attr('y', '-50%').attr('width', '200%').attr('height', '200%');
558
- glowFilter.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'blur');
559
- glowFilter.append('feMerge').html('<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>');
560
- const bridgeGlowF = defs.append('filter').attr('id', 'bridge-glow').attr('x', '-80%').attr('y', '-80%').attr('width', '260%').attr('height', '260%');
561
- bridgeGlowF.append('feGaussianBlur').attr('stdDeviation', '8').attr('result', 'blur');
562
- bridgeGlowF.append('feMerge').html('<feMergeNode in="blur"/><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>');
563
- defs.append('style').text(`
564
- @keyframes pulse { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
565
- @keyframes selectedPulse { 0%,100% { stroke-opacity: 0.4; r: attr(r); } 50% { stroke-opacity: 1; } }
566
- .bridge-pulse { animation: pulse 2s ease-in-out infinite; }
567
- @keyframes boundary-breathe { 0%,100% { stroke-opacity: 0.5; } 50% { stroke-opacity: 0.75; } }
568
- .boundary-ring { animation: boundary-breathe 3s ease-in-out infinite; }
569
- `);
570
- const boundaryGlow = defs.append('filter').attr('id', 'boundary-glow').attr('x', '-20%').attr('y', '-20%').attr('width', '140%').attr('height', '140%');
571
- boundaryGlow.append('feGaussianBlur').attr('stdDeviation', '3').attr('result', 'blur');
572
- boundaryGlow.append('feMerge').html('<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>');
573
-
574
- // --- Cluster decoration groups (move as a unit on tick) ---
575
- const clusterGroups = bridgeNodes.map((bridge, bi) => {
576
- const cg = g.append('g');
577
- const color = bridge._clusterColor;
578
- const d2Count = d2Nodes.filter(n => n.source_connection_id === bridge.id).length;
579
- const sCount = d2Nodes.filter(n => n.source_connection_id === bridge.id && n.tier === 'S').length;
580
-
581
- // Nebula background
582
- cg.append('circle').attr('r', 200).attr('fill', `url(#nebula-${bi})`);
583
- // Tier band separators — filled rings between each tier for visual layering
584
- const tierBands = [
585
- { inner: 0, outer: 55, color: tierColors['S'], opacity: 0.06 }, // S zone (innermost)
586
- { inner: 55, outer: 85, color: tierColors['A'], opacity: 0.05 }, // A zone
587
- { inner: 85, outer: 112, color: tierColors['B'], opacity: 0.04 }, // B zone
588
- { inner: 112, outer: 137, color: tierColors['C'], opacity: 0.03 },// C zone
589
- { inner: 137, outer: 165, color: tierColors['D'], opacity: 0.02 },// D zone (outermost)
590
- ];
591
- tierBands.forEach(band => {
592
- // Filled band
593
- const arc = d3.arc().innerRadius(band.inner).outerRadius(band.outer).startAngle(0).endAngle(2 * Math.PI);
594
- cg.append('path').attr('d', arc()).attr('fill', band.color).attr('opacity', band.opacity);
595
- });
596
- // Tier ring lines — colored per tier
597
- const ringDefs = [
598
- { r: 40, color: tierColors['S'], w: 1.5, dash: 'none' },
599
- { r: 70, color: tierColors['A'], w: 0.8, dash: '4,4' },
600
- { r: 100, color: tierColors['B'], w: 0.8, dash: '4,4' },
601
- { r: 125, color: tierColors['C'], w: 0.6, dash: '3,5' },
602
- { r: 145, color: tierColors['D'], w: 0.6, dash: '3,5' },
603
- ];
604
- ringDefs.forEach(rd => {
605
- cg.append('circle').attr('r', rd.r).attr('fill', 'none')
606
- .attr('stroke', rd.color).attr('stroke-opacity', 0.2)
607
- .attr('stroke-width', rd.w).attr('stroke-dasharray', rd.dash);
608
- });
609
- // Glow halo
610
- cg.append('circle').attr('r', 25).attr('fill', color).attr('opacity', 0.15).attr('class', 'bridge-pulse');
611
- // Catalyst badge
612
- if (bridge.is_catalyst) {
613
- cg.append('rect').attr('x', -40).attr('y', -185).attr('width', 80).attr('height', 18)
614
- .attr('rx', 9).attr('fill', CATALYST_COLOR).attr('opacity', 0.9);
615
- cg.append('text').attr('y', -172).text('CATALYST').attr('fill', '#000').attr('font-size', 9)
616
- .attr('font-weight', 800).attr('text-anchor', 'middle').attr('letter-spacing', 1);
617
- }
618
- // Title
619
- cg.append('text').attr('y', bridge.is_catalyst ? -160 : -170)
620
- .text(bridge.name + (bridge.company ? ' · ' + bridge.company : ''))
621
- .attr('fill', color).attr('font-size', 10).attr('font-weight', 600)
622
- .attr('text-anchor', 'middle').attr('opacity', 0.8);
623
- cg.append('text').attr('y', bridge.is_catalyst ? -148 : -158)
624
- .text(d2Count + ' connections · ' + sCount + ' S-tier')
625
- .attr('fill', 'rgba(255,255,255,0.35)').attr('font-size', 8).attr('text-anchor', 'middle');
626
-
627
- return cg;
628
- });
629
-
630
- // --- Animated cluster boundaries ---
631
- // Each cluster gets a solid glowing ring that NEVER overlaps another cluster.
632
- // Where two clusters are close, both boundaries flatten at the exact midpoint.
633
- const boundaryR = 170;
634
- const boundaryPoints = 64;
635
- const boundaryPaths = bridgeNodes.map((bridge, bi) => {
636
- // Boundary color follows the outermost tier (D) — creates the "containment" feel
637
- // Inner tiers glow their own color, outer boundary is neutral
638
- return g.append('path')
639
- .attr('fill', 'none')
640
- .attr('stroke', tierColors['D'])
641
- .attr('stroke-opacity', 0.5)
642
- .attr('stroke-width', 2.5)
643
- .attr('stroke-linecap', 'round')
644
- .attr('stroke-linejoin', 'round')
645
- .attr('filter', 'url(#boundary-glow)')
646
- .attr('class', 'boundary-ring');
647
- });
648
-
649
- function updateBoundaries() {
650
- bridgeNodes.forEach((bridge, bi) => {
651
- const pts = [];
652
- for (let p = 0; p < boundaryPoints; p++) {
653
- const angle = (2 * Math.PI * p) / boundaryPoints;
654
- let r = boundaryR;
655
-
656
- // Soft deform: boundary bends smoothly toward nearby clusters, never crossing midpoint
657
- for (let oi = 0; oi < bridgeNodes.length; oi++) {
658
- if (bi === oi) continue;
659
- const other = bridgeNodes[oi];
660
- const dx = other.x - bridge.x, dy = other.y - bridge.y;
661
- const distBetween = Math.sqrt(dx * dx + dy * dy);
662
- if (distBetween < boundaryR * 2.5) {
663
- const angleToOther = Math.atan2(dy, dx);
664
- let angleDiff = Math.abs(angle - angleToOther);
665
- if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
666
- if (angleDiff < Math.PI / 1.5) {
667
- const maxR = (distBetween / 2) - 6;
668
- // Smooth cosine blend — creates a gentle dent, not a hard flat
669
- const influence = Math.pow(Math.cos(angleDiff * 1.5 / Math.PI * (Math.PI / 2)), 2);
670
- const targetR = Math.max(maxR, 35);
671
- if (targetR < r) {
672
- r = r * (1 - influence) + targetR * influence;
673
- }
674
- }
675
- }
676
- }
677
- pts.push([bridge.x + Math.cos(angle) * r, bridge.y + Math.sin(angle) * r]);
678
- }
679
- boundaryPaths[bi].attr('d', d3.line().curve(d3.curveCardinalClosed.tension(0.3))(pts));
680
- });
681
- }
682
-
683
- // --- Bridge connecting lines ---
684
- const bridgeLines = g.append('g').selectAll('line').data(bridgeNodes).join('line')
685
- .attr('stroke', d => d._clusterColor).attr('stroke-opacity', 0.4).attr('stroke-width', 2.5);
686
-
687
- // --- Discovery chain lines ---
688
- const chainData = bridgeNodes.filter(b => b.unlocked_from_bridge_id && bridgeById[b.unlocked_from_bridge_id]);
689
- const chainLines = g.append('g').selectAll('line').data(chainData).join('line')
690
- .attr('stroke', '#FF6B35').attr('stroke-width', 1.5)
691
- .attr('stroke-dasharray', '6,4').attr('stroke-opacity', 0.5);
692
- const chainLabels = g.append('g').selectAll('text').data(chainData).join('text')
693
- .text(d => 'unlocked via ' + (d.unlocked_from_name || bridgeById[d.unlocked_from_bridge_id]?.name || ''))
694
- .attr('fill', 'rgba(255,107,53,0.5)').attr('font-size', 8)
695
- .attr('text-anchor', 'middle').style('pointer-events', 'none');
696
-
697
- // --- D2 links (subtle, rendered after settle) ---
698
- let d2LinkGroup = null;
699
-
700
- // --- Nodes ---
701
- const node = g.append('g').selectAll('circle').data(nodes).join('circle')
702
- .attr('r', nodeRadius)
703
- .attr('fill', d => {
704
- if (d.nodeType === 'center') return '#fff';
705
- if (d.nodeType === 'bridge') return tierColors[d.tier] || '#FFD700';
706
- return tierColors[d.tier] || '#666';
707
- })
708
- .attr('stroke', d => {
709
- if (d.nodeType === 'center') return '#FFD700';
710
- if (d.nodeType === 'bridge') return '#fff';
711
- if (d.isMutual) return '#00ff88';
712
- if (d.tier === 'S') return 'rgba(255,215,0,0.5)';
713
- return 'none';
714
- })
715
- .attr('stroke-width', d => {
716
- if (d.nodeType === 'center') return 3;
717
- if (d.nodeType === 'bridge') return 3;
718
- if (d.isMutual) return 2.5;
719
- if (d.tier === 'S') return 1.5;
720
- return 0;
721
- })
722
- .attr('filter', d => (d.nodeType === 'bridge') ? 'url(#bridge-glow)' : (d.tier === 'S' ? 'url(#glow)' : null))
723
- .style('cursor', 'pointer');
724
-
725
- // --- Labels ---
726
- const labelNodes = nodes.filter(n =>
727
- n.nodeType === 'center' || n.nodeType === 'bridge' || (n.degree === 2 && n.power_score >= 7)
728
- );
729
- const labels = g.append('g').selectAll('text').data(labelNodes).join('text')
730
- .text(d => d.name)
731
- .attr('font-size', d => d.nodeType === 'center' ? 15 : d.nodeType === 'bridge' ? 13 : 9)
732
- .attr('font-weight', d => d.nodeType === 'center' || d.nodeType === 'bridge' ? 700 : 600)
733
- .attr('fill', d => {
734
- if (d.nodeType === 'center') return '#fff';
735
- if (d.nodeType === 'bridge') return '#fff';
736
- return tierColors[d.tier] || '#FF6B35';
737
- })
738
- .attr('text-anchor', 'middle')
739
- .attr('dy', d => nodeRadius(d) + (d.nodeType === 'bridge' ? 24 : 15))
740
- .style('pointer-events', 'none')
741
- .style('text-shadow', d => d.nodeType === 'bridge' ? '0 0 10px rgba(0,0,0,0.8)' : 'none');
742
-
743
- // --- Force simulation ---
744
- // Bridges: positioned by physics (orbit + charge)
745
- // D2 nodes: positioned by computed angles (no physics fighting)
746
- const bridgeLinks = links.filter(l => l.linkType === 'bridge');
747
-
748
- // Pre-compute even angular positions for every d2 node per tier per cluster
749
- const nodeTargets = new Map();
750
- const clustersByBridge = {};
751
- d2Nodes.forEach(n => {
752
- const cid = n.source_connection_id;
753
- if (!clustersByBridge[cid]) clustersByBridge[cid] = {};
754
- const tier = n.tier || 'D';
755
- if (!clustersByBridge[cid][tier]) clustersByBridge[cid][tier] = [];
756
- clustersByBridge[cid][tier].push(n);
757
- });
758
- // Offset each tier's starting angle so rings don't stack on top of each other
759
- const tierOffset = { S: 0, A: Math.PI / 5, B: Math.PI / 3, C: Math.PI / 2.2, D: Math.PI / 1.7 };
760
- // Max radius is inside the boundary line
761
- const maxNodeRadius = 160;
762
- for (const cid in clustersByBridge) {
763
- for (const tier in clustersByBridge[cid]) {
764
- const group = clustersByBridge[cid][tier];
765
- const radius = Math.min(tierRing[tier] || 130, maxNodeRadius);
766
- const offset = tierOffset[tier] || 0;
767
- group.forEach((n, i) => {
768
- const angle = (2 * Math.PI * i) / group.length + offset;
769
- nodeTargets.set(n.id, { angle, radius, bridgeId: cid });
770
- });
771
- }
772
- }
773
-
774
- const simulation = d3.forceSimulation(nodes)
775
- .force('charge', d3.forceManyBody().strength(d => {
776
- if (d.nodeType === 'center') return 0;
777
- if (d.nodeType === 'bridge') return -400;
778
- return 0; // d2 has NO charge — positions are computed, not physics-driven
779
- }))
780
- .force('bridgeOrbit', (() => {
781
- let _nodes, _center;
782
- const force = (alpha) => {
783
- if (!_center) return;
784
- const bx = _center.fx ?? _center.x, by = _center.fy ?? _center.y;
785
- for (const n of _nodes) {
786
- if (n.nodeType !== 'bridge') continue;
787
- const targetR = n._orbitRadius || 500;
788
- const dx = n.x - bx, dy = n.y - by;
789
- const dist = Math.sqrt(dx * dx + dy * dy) || 1;
790
- const diff = dist - targetR;
791
- n.vx -= (dx / dist) * diff * alpha * 0.4;
792
- n.vy -= (dy / dist) * diff * alpha * 0.4;
793
- }
794
- };
795
- force.initialize = (n) => { _nodes = n; _center = n.find(x => x.id === CENTER_ID); };
796
- return force;
797
- })())
798
- .force('clusterPositioning', (() => {
799
- // Each d2 node has a pre-computed angle + radius relative to its bridge.
800
- // This force pulls it to that exact spot. No charge needed, no bunching possible.
801
- // Nodes form perfect evenly-spaced circles on each tier ring.
802
- let _nodes;
803
- const force = (alpha) => {
804
- for (const n of _nodes) {
805
- if (n.nodeType !== 'degree2') continue;
806
- const target = nodeTargets.get(n.id);
807
- if (!target) continue;
808
- const bridge = bridgeById[target.bridgeId];
809
- if (!bridge) continue;
810
- let r = target.radius;
811
- // Match boundary deformation exactly — dots must stay inside
812
- // Uses the SAME math as updateBoundaries() so they track together
813
- for (let oi = 0; oi < bridgeNodes.length; oi++) {
814
- const other = bridgeNodes[oi];
815
- if (other.id === target.bridgeId) continue;
816
- const dx = other.x - bridge.x, dy = other.y - bridge.y;
817
- const distBetween = Math.sqrt(dx * dx + dy * dy);
818
- if (distBetween < boundaryR * 2.5) {
819
- const angleToOther = Math.atan2(dy, dx);
820
- let angleDiff = Math.abs(target.angle - angleToOther);
821
- if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
822
- if (angleDiff < Math.PI / 1.5) {
823
- // Boundary sits at (distBetween/2 - 6), dots need margin inside that
824
- const boundaryAtAngle = (distBetween / 2) - 6;
825
- const dotMax = boundaryAtAngle - 12; // 12px inside boundary
826
- const influence = Math.pow(Math.cos(angleDiff * 1.5 / Math.PI * (Math.PI / 2)), 2);
827
- const targetR = Math.max(dotMax, 25);
828
- if (targetR < r) {
829
- r = r * (1 - influence) + targetR * influence;
830
- }
831
- }
832
- }
833
- }
834
- const tx = bridge.x + Math.cos(target.angle) * r;
835
- const ty = bridge.y + Math.sin(target.angle) * r;
836
- n.vx += (tx - n.x) * alpha * 0.6;
837
- n.vy += (ty - n.y) * alpha * 0.6;
838
- }
839
- };
840
- force.initialize = (n) => { _nodes = n; };
841
- return force;
842
- })())
843
- .force('collision', d3.forceCollide().radius(d => d.nodeType === 'bridge' ? 20 : 0))
844
- .force('link', d3.forceLink(bridgeLinks).id(d => d.id)
845
- .distance(d => d.target?._orbitRadius || 500).strength(0.01)
846
- )
847
- .alphaDecay(0.02)
848
- .velocityDecay(0.4);
849
-
850
- // --- Tick: update all positions ---
851
- let settled = false;
852
- simulation.on('tick', () => {
853
- // Nodes
854
- node.attr('cx', d => d.x).attr('cy', d => d.y);
855
- labels.attr('x', d => d.x).attr('y', d => d.y);
856
- // Cluster decorations follow their bridge
857
- clusterGroups.forEach((cg, i) => {
858
- cg.attr('transform', `translate(${bridgeNodes[i].x},${bridgeNodes[i].y})`);
859
- });
860
- // Animated boundaries — deform where clusters overlap
861
- updateBoundaries();
862
- // Bridge connecting lines
863
- const centerX = centerNode.fx ?? centerNode.x, centerY = centerNode.fy ?? centerNode.y;
864
- bridgeLines.attr('x1', centerX).attr('y1', centerY)
865
- .attr('x2', d => d.x).attr('y2', d => d.y);
866
- // Discovery chain lines
867
- chainLines
868
- .attr('x1', d => bridgeById[d.unlocked_from_bridge_id]?.x || 0)
869
- .attr('y1', d => bridgeById[d.unlocked_from_bridge_id]?.y || 0)
870
- .attr('x2', d => d.x).attr('y2', d => d.y);
871
- chainLabels
872
- .attr('x', d => ((bridgeById[d.unlocked_from_bridge_id]?.x || 0) + d.x) / 2)
873
- .attr('y', d => ((bridgeById[d.unlocked_from_bridge_id]?.y || 0) + d.y) / 2 - 8);
874
- // D2 links — render once after simulation settles
875
- if (!settled && simulation.alpha() < 0.05) {
876
- settled = true;
877
- const d2Links = links.filter(l => l.linkType === 'degree2');
878
- d2LinkGroup = g.insert('g', ':first-child').selectAll('line').data(d2Links).join('line')
879
- .attr('x1', d => d.source.x).attr('y1', d => d.source.y)
880
- .attr('x2', d => d.target.x).attr('y2', d => d.target.y)
881
- .attr('stroke', 'rgba(255,255,255,0.04)').attr('stroke-width', 0.3);
882
- }
883
- if (d2LinkGroup) {
884
- d2LinkGroup.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
885
- .attr('x2', d => d.target.x).attr('y2', d => d.target.y);
886
- }
887
- });
888
-
889
- // --- Drag ---
890
- setupDrag(node, simulation, CENTER_ID);
891
-
892
- // --- Tooltip + Photo Hover (same as Network Map) ---
893
- const tooltip = createTooltip();
894
- const photoTooltip = d3.select('body').append('div')
895
- .attr('class', 'graph-tooltip')
896
- .style('position', 'fixed').style('top', '0px').style('left', '0px')
897
- .style('pointer-events', 'none').style('opacity', 0)
898
- .style('z-index', 1001).style('transition', 'opacity 0.15s');
899
-
900
- node.on('mouseover', function (event, d) {
901
- d3.select(this).attr('r', nodeRadius(d) * 1.5).attr('filter', 'url(#glow)');
902
-
903
- // Photo hover
904
- if (d.profile_image_url && d.id !== CENTER_ID) {
905
- const size = Math.max(48, nodeRadius(d) * 4);
906
- photoTooltip.style('opacity', 1);
907
- renderPhoto(photoTooltip, d, size, tierColors[d.tier] || '#555');
908
- photoTooltip
909
- .style('left', (event.clientX - size / 2) + 'px')
910
- .style('top', (event.clientY - size - 8) + 'px');
911
- }
912
-
913
- // Text tooltip below
914
- const bridgeFor = d.nodeType === 'bridge'
915
- ? d2Nodes.filter(n => n.source_connection_id === d.id).length + ' connections' : null;
916
- const viaBridge = d.nodeType === 'degree2'
917
- ? bridgeNodes.find(b => b.id === d.source_connection_id)?.name : null;
918
- tooltip.style('opacity', 1)
919
- .html([
920
- `<strong>${esc(d.name)}</strong>`,
921
- d.role ? esc(d.role.substring(0, 60)) : '',
922
- d.company ? `<span style="color:${tierColors[d.tier] || '#888'}">@ ${esc(d.company)}</span>` : '',
923
- d.tier && d.tier !== 'center' ? `Tier ${esc(d.tier)} · Score: ${parseFloat(d.power_score).toFixed(1)}` : '',
924
- bridgeFor ? `<span style="color:#FFD700">${esc(bridgeFor)}</span>` : '',
925
- viaBridge ? `<span style="color:#FF6B35">via ${esc(viaBridge)}</span>` : '',
926
- d.isMutual ? '<span style="color:#00ff88">Mutual connection</span>' : '',
927
- ].filter(Boolean).join('<br/>'))
928
- .style('left', (event.clientX + 12) + 'px').style('top', (event.clientY + 12) + 'px');
929
- }).on('mousemove', function (event, d) {
930
- if (d.profile_image_url && d.id !== CENTER_ID) {
931
- const size = Math.max(48, nodeRadius(d) * 4);
932
- photoTooltip.style('left', (event.clientX - size / 2) + 'px').style('top', (event.clientY - size - 8) + 'px');
933
- }
934
- tooltip.style('left', (event.clientX + 12) + 'px').style('top', (event.clientY + 12) + 'px');
935
- }).on('mouseout', function (event, d) {
936
- d3.select(this).attr('r', nodeRadius(d))
937
- .attr('filter', (d.nodeType === 'bridge') ? 'url(#bridge-glow)' : (d.tier === 'S' ? 'url(#glow)' : null));
938
- tooltip.style('opacity', 0);
939
- photoTooltip.style('opacity', 0);
940
- });
941
-
942
- // --- Click: select + Connect to Unlock overlay ---
943
- node.on('click', (event, d) => {
944
- if (d.id === CENTER_ID) return;
945
- // Selection glow
946
- g.selectAll('.selected-glow').remove();
947
- g.append('circle')
948
- .attr('class', 'selected-glow')
949
- .attr('cx', d.x).attr('cy', d.y).attr('r', nodeRadius(d) + 8)
950
- .attr('fill', 'none').attr('stroke', tierColors[d.tier] || '#FFD700')
951
- .attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
952
- .style('animation', 'selectedPulse 1.5s ease-in-out infinite');
953
- onSelect(d);
954
- // Bridge click: zoom into this cluster
955
- if (d.nodeType === 'bridge' && zoomToClusterRef?.current) {
956
- zoomToClusterRef.current(d);
957
- // Update React state via a custom event
958
- window.dispatchEvent(new CustomEvent('cluster-focus', { detail: { id: d.id, name: d.name } }));
959
- }
960
- g.selectAll('.unlock-overlay').remove();
961
- if (d.nodeType === 'degree2' && (d.tier === 'S' || d.tier === 'A') && d.unlock_status !== 'unlocked') {
962
- simulation.stop();
963
- const ox = d.x, oy = d.y;
964
- const overlay = g.append('g').attr('class', 'unlock-overlay');
965
- // Dim non-path nodes
966
- node.transition().duration(300)
967
- .attr('opacity', n => (n.id === d.id || n.id === CENTER_ID || n.id === d.source_connection_id) ? 1 : 0.2);
968
- labels.transition().duration(300)
969
- .attr('opacity', n => (n.id === d.id || n.id === CENTER_ID || n.id === d.source_connection_id) ? 1 : 0.1);
970
- // Phantom cluster
971
- [30, 55, 80].forEach(r => {
972
- overlay.append('circle').attr('cx', ox).attr('cy', oy).attr('r', r)
973
- .attr('fill', 'none').attr('stroke', 'rgba(255,215,0,0.12)')
974
- .attr('stroke-dasharray', '4,4').attr('stroke-width', 0.8);
975
- });
976
- for (let i = 0; i < 8; i++) {
977
- const a = (Math.PI * 2 * i) / 8, r = 30 + Math.random() * 50;
978
- overlay.append('circle').attr('cx', ox + Math.cos(a)*r).attr('cy', oy + Math.sin(a)*r)
979
- .attr('r', 3 + Math.random()*3).attr('fill', 'rgba(255,255,255,0.06)')
980
- .attr('stroke', 'rgba(255,255,255,0.08)').attr('stroke-width', 0.5);
981
- }
982
- // Path highlight
983
- const bridge = bridgeById[d.source_connection_id];
984
- const center = centerNode;
985
- if (center && bridge) {
986
- overlay.append('line').attr('x1', center.x).attr('y1', center.y).attr('x2', bridge.x).attr('y2', bridge.y)
987
- .attr('stroke', '#FFD700').attr('stroke-width', 2).attr('stroke-opacity', 0.6);
988
- overlay.append('line').attr('x1', bridge.x).attr('y1', bridge.y).attr('x2', ox).attr('y2', oy)
989
- .attr('stroke', '#FF6B35').attr('stroke-width', 2).attr('stroke-opacity', 0.6);
990
- }
991
- // Status card
992
- const isPending = d.unlock_status === 'pending';
993
- overlay.append('rect').attr('x', ox-70).attr('y', oy-115).attr('width', 140).attr('height', isPending ? 40 : 55)
994
- .attr('rx', 8).attr('fill', 'rgba(0,0,0,0.92)')
995
- .attr('stroke', isPending ? 'rgba(255,165,0,0.5)' : 'rgba(255,215,0,0.4)').attr('stroke-width', 1);
996
- overlay.append('text').attr('x', ox).attr('y', oy-97)
997
- .text(isPending ? 'PENDING...' : 'LOCKED PATH')
998
- .attr('fill', isPending ? '#FFA500' : '#FFD700').attr('font-size', 9).attr('font-weight', 800)
999
- .attr('text-anchor', 'middle').attr('letter-spacing', 1);
1000
- if (!isPending) {
1001
- overlay.append('rect').attr('x', ox-60).attr('y', oy-84).attr('width', 120).attr('height', 24).attr('rx', 6)
1002
- .attr('fill', '#FFD700').style('cursor', 'pointer')
1003
- .on('click', (e) => {
1004
- e.stopPropagation();
1005
- const bridgeNode = bridgeById[d.source_connection_id];
1006
- fetch('/api/unlock', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({connectionId:d.id, bridgeId:d.source_connection_id, bridgeName:bridgeNode?.name||''}) }).catch(()=>{});
1007
- window.open(d.profile_url, '_blank');
1008
- overlay.select('text').text('PENDING...');
1009
- });
1010
- overlay.append('text').attr('x', ox).attr('y', oy-68).text('Connect to Unlock')
1011
- .attr('fill', '#000').attr('font-size', 10).attr('font-weight', 700).attr('text-anchor', 'middle').style('pointer-events', 'none');
1012
- } else {
1013
- overlay.append('text').attr('x', ox).attr('y', oy-82).text('Run a scan to check')
1014
- .attr('fill', '#888').attr('font-size', 9).attr('text-anchor', 'middle');
1015
- }
1016
- event.stopPropagation();
1017
- svg.on('click.unlock', () => {
1018
- node.transition().duration(300).attr('opacity', 1);
1019
- labels.transition().duration(300).attr('opacity', 1);
1020
- g.selectAll('.unlock-overlay').remove();
1021
- svg.on('click.unlock', null);
1022
- simulation.alpha(0.1).restart();
1023
- });
1024
- }
1025
- });
1026
-
1027
- // --- Auto-zoom to fit the layout (only on first render, not when restoring zoom) ---
1028
- if (!savedTransform) {
1029
- const maxOrbit = bridgeNodes.length > 0 ? Math.max(...bridgeNodes.map(b => b._orbitRadius || 500)) : 500;
1030
- const fitR = maxOrbit + 200;
1031
- const fitScale = Math.min(width / (fitR * 2.2), height / (fitR * 2.2), 0.85);
1032
- const initialTransform = d3.zoomIdentity.translate(width/2, height/2).scale(fitScale).translate(-cx, -cy);
1033
- svg.call(zoomBehavior.transform, initialTransform);
1034
- if (zoomTransformRef) zoomTransformRef.current = initialTransform;
1035
- }
1036
- return simulation;
451
+ return { simulation, resize, select };
1037
452
  }
1038
453
 
1039
454
  function setupDrag(node, simulation, fixedId) {
@@ -1042,6 +457,6 @@ function setupDrag(node, simulation, fixedId) {
1042
457
  .on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
1043
458
  .on('end', (event, d) => {
1044
459
  if (!event.active) simulation.alphaTarget(0);
1045
- if (d.id !== fixedId && d.nodeType !== 'bridge') { d.fx = null; d.fy = null; }
460
+ if (d.id !== fixedId) { d.fx = null; d.fy = null; }
1046
461
  }));
1047
462
  }