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
@@ -1,17 +1,120 @@
1
1
  'use client';
2
2
 
3
- import { useState, useRef, useEffect } from 'react';
3
+ // Bridge Chains: your bridges round you. Click one for their circle, and any
4
+ // dot in it for that person's circle in turn, as far as your scans reach.
5
+ //
6
+ // Blake, 2026-09-28: "for bridges when i scan someone and have a lot of 2nd
7
+ // degree its almost a solid line and needs to expand more and we need to be
8
+ // able to click on the actual 2nd degree within the bridge so we can see the
9
+ // extended cluster for the d3 dots as it builds more in".
10
+ //
11
+ // - A circle's dots fill rings from the inside out, as many as its count needs
12
+ // (lib/chain-layout.js), so 800 people spread out instead of piling into one
13
+ // line. Drag to move, scroll or +/− to zoom.
14
+ // - Any dot opens that person's own circle in place: the people found in
15
+ // THEIR circle once you connected and scanned it (3rd degree, counted along
16
+ // the chain), from the two facts lib/circle.js reads. With none yet it says
17
+ // why, and what gets them. The trail at the top, and Esc, go back. The page
18
+ // looks at the network again while a circle is being scanned, so an open
19
+ // circle fills in as the scanner saves (app/page.js, NetworkRefresh). A dot
20
+ // ready for a scan goes to the Scan page with them picked instead: their
21
+ // circle is empty until it's scanned.
22
+ // - People you reached through a circle are marked by lib/reach.js: a soft
23
+ // breathing halo when their own circle is ready for a scan (still, with
24
+ // Reduce Motion on), greyed with a lock when their list is hidden. Each
25
+ // bridge says how many are ready. The glow this replaced looked for them
26
+ // inside the circle they came from, and accepting takes them out of it
27
+ // (lib/promote.js), so it never showed. Backlog 2.4.
28
+
29
+ import { useState, useRef, useEffect, useMemo, useSyncExternalStore } from 'react';
30
+ import Link from 'next/link';
31
+ import { useRouter } from 'next/navigation';
32
+ import { circleIndex } from '../../lib/circle';
33
+ import { localPhoto } from '../../lib/photos';
34
+ import { reachIndex, reachState, circleState, readyByCircle, circleScanCost } from '../../lib/reach';
35
+ import { ringLayout, dotRadius } from '../../lib/chain-layout';
36
+ import { keyFor, score } from '../../lib/separation';
37
+ import { hasRequest } from '../../lib/requests-client';
38
+ import { watchScanner, scannerNow, isCircleScan } from '../../lib/scraper-client';
39
+ import useRequests from './useRequests';
4
40
 
5
41
  const TIER_COLORS = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
6
42
  const DEGREE_COLORS = { 1: '#FFD700', 2: '#FF6B35', 3: '#3498DB', 4: '#9B59B6', 5: '#00ff88', 6: '#ff5050' };
43
+ const TIER_RANK = { S: 0, A: 1, B: 2, C: 3, D: 4 };
44
+ const GREEN = '#00ff88';
45
+ const HIDDEN = '#5a5a66';
46
+
47
+ const byTierThenScore = (a, b) => (TIER_RANK[a.tier] ?? 9) - (TIER_RANK[b.tier] ?? 9)
48
+ || score(b) - score(a)
49
+ || String(a.name || '').localeCompare(String(b.name || ''));
50
+ const firstName = (row) => String(row?.name || '').trim().split(/\s+/)[0] || 'them';
51
+ // The Scan page with them picked; nothing starts until it's confirmed there.
52
+ const scanPageFor = (row) => `/setup?scan=${encodeURIComponent(row.id)}`;
53
+ const ordinal = (n) => `${n}${n % 10 === 1 && n !== 11 ? 'st' : n % 10 === 2 && n !== 12 ? 'nd' : n % 10 === 3 && n !== 13 ? 'rd' : 'th'}`;
54
+
55
+ // Whose circle is being scanned now, by id: a string, so the view re-renders
56
+ // only when it changes, not on every line of the scan's log.
57
+ const scanningNow = () => {
58
+ const s = scannerNow();
59
+ return s.running && isCircleScan(s) ? s.target?.id ?? null : null;
60
+ };
61
+ const scanningNone = () => null;
62
+ const watchNothing = () => () => {};
63
+
64
+ // Reduce Motion: the halo breathes only when it's off.
65
+ const STILL = '(prefers-reduced-motion: reduce)';
66
+ function watchMotion(onChange) {
67
+ const m = typeof window !== 'undefined' && window.matchMedia ? window.matchMedia(STILL) : null;
68
+ m?.addEventListener?.('change', onChange);
69
+ return () => m?.removeEventListener?.('change', onChange);
70
+ }
71
+ const stillNow = () => Boolean(typeof window !== 'undefined' && window.matchMedia?.(STILL).matches);
72
+ const stillOnServer = () => true;
73
+
74
+ /** Who sits in `person`'s circle: the people you reached through it first, then everyone its scan found. */
75
+ function membersOf(person, index) {
76
+ const seen = new Set([keyFor(person)]);
77
+ const take = (rows) => {
78
+ const out = [];
79
+ for (const row of rows || []) {
80
+ const key = keyFor(row);
81
+ if (seen.has(key)) continue;
82
+ seen.add(key);
83
+ out.push(row);
84
+ }
85
+ return out.sort(byTierThenScore);
86
+ };
87
+ return [...take(index.introduced.get(person.id)), ...take(index.circles.get(person.id))];
88
+ }
89
+
90
+ /** The trail to open for a link: the circle they came from, then them. */
91
+ function trailTo(id, rows) {
92
+ if (!id) return [];
93
+ const row = rows.find((r) => r.id === id);
94
+ if (!row) return [];
95
+ const from = row.unlocked_from_bridge_id;
96
+ return from != null && rows.some((r) => r.id === from) ? [from, id] : [id];
97
+ }
7
98
 
8
- export default function ChainView({ connections, degree2 = [], onSelect, userName }) {
99
+ export default function ChainView({ connections, degree2 = [], onSelect, userName, fullDegree1, fullDegree2, scanNotes, canScan = true, chainOpen = null, onChainOpened }) {
9
100
  const containerRef = useRef(null);
10
101
  const [hovered, setHovered] = useState(null);
11
- const [focusedBridge, setFocusedBridge] = useState(null);
12
102
  const [zoom, setZoom] = useState(1.3);
13
103
  const [dims, setDims] = useState({ w: 800, h: 600 });
14
- const [isTouching, setIsTouching] = useState(false);
104
+ // Every row, whatever the tier filter: a circle opened from a bridge the
105
+ // filter shows is drawn whole, and so is anyone's circle opened from it.
106
+ const all1 = fullDegree1 || connections;
107
+ const all2 = fullDegree2 || degree2;
108
+ // The circles opened, outermost last: [bridge id, person id, …]; [] is the overview.
109
+ const [path, setPath] = useState(() => trailTo(chainOpen, all1));
110
+ // A link is followed once: coming back to this view later starts from the overview.
111
+ useEffect(() => {
112
+ if (chainOpen) onChainOpened?.();
113
+ }, [chainOpen, onChainOpened]);
114
+ // The sample and a CSV have no scans, so nothing to ask the scanner.
115
+ const scanningId = useSyncExternalStore(canScan ? watchScanner : watchNothing, scanningNow, scanningNone);
116
+ const still = useSyncExternalStore(watchMotion, stillNow, stillOnServer);
117
+ const requests = useRequests();
15
118
 
16
119
  useEffect(() => {
17
120
  const el = containerRef.current;
@@ -32,33 +135,81 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
32
135
  return () => ro.disconnect();
33
136
  }, []);
34
137
 
35
- // Touch rotary dial — drag finger around circle to highlight bridges
36
- // Pre-compute bridge angles once (not on every touch event)
37
- const bridgeAnglesRef = useRef([]);
138
+ const index = useMemo(() => circleIndex(all1, all2), [all1, all2]);
139
+ const reach = useMemo(() => reachIndex(all1, all2, scanNotes), [all1, all2, scanNotes]);
140
+ const readyCount = useMemo(() => readyByCircle(reach), [reach]);
141
+ const rowById = useMemo(() => {
142
+ const m = new Map();
143
+ for (const r of all2) m.set(r.id, r);
144
+ for (const r of all1) m.set(r.id, r);
145
+ return m;
146
+ }, [all1, all2]);
147
+ // The trail as rows. Anyone no longer on file ends it there.
148
+ const trail = [];
149
+ for (const id of path) {
150
+ const row = rowById.get(id);
151
+ if (!row) break;
152
+ trail.push(row);
153
+ }
154
+ const focused = trail.length > 0;
155
+
156
+ // Esc goes back one circle, unless it's closing something else (the card's
157
+ // large map) or leaving a text box.
38
158
  useEffect(() => {
39
- const bMap = {};
40
- degree2.forEach(d2 => { if (d2.source_connection_id) { if (!bMap[d2.source_connection_id]) bMap[d2.source_connection_id] = []; bMap[d2.source_connection_id].push(d2); }});
41
- const br = connections.filter(c => bMap[c.id]).sort((a, b) => (bMap[b.id]?.length || 0) - (bMap[a.id]?.length || 0));
42
- bridgeAnglesRef.current = br.map((b, i) => ({ id: b.id, angle: (i / br.length) * Math.PI * 2 - Math.PI / 2 }));
43
- }, [connections, degree2]);
159
+ if (!focused) return undefined;
160
+ const onKey = (e) => {
161
+ if (e.key !== 'Escape' || /^(INPUT|TEXTAREA|SELECT)$/.test(e.target?.tagName || '')) return;
162
+ if (document.querySelector('[role="dialog"][aria-modal="true"]')) return;
163
+ setPath((p) => p.slice(0, -1));
164
+ };
165
+ window.addEventListener('keydown', onKey);
166
+ return () => window.removeEventListener('keydown', onKey);
167
+ }, [focused]);
168
+
169
+ // Build bridge map
170
+ const bridgeMap = {};
171
+ degree2.forEach(d2 => {
172
+ if (!d2.source_connection_id) return;
173
+ if (!bridgeMap[d2.source_connection_id]) bridgeMap[d2.source_connection_id] = [];
174
+ bridgeMap[d2.source_connection_id].push(d2);
175
+ });
176
+
177
+ const bridges = connections.filter(c => bridgeMap[c.id] && bridgeMap[c.id].length > 0)
178
+ .sort((a, b) => (bridgeMap[b.id]?.length || 0) - (bridgeMap[a.id]?.length || 0));
179
+
180
+ const cx = dims.w / 2;
181
+ const cy = dims.h / 2;
182
+ const maxR = Math.min(cx, cy) - 30;
44
183
 
184
+ // Bridges round you: one ring while they fit, more once there are many.
185
+ const bridgeLayout = ringLayout(bridges.length, {
186
+ inner: maxR * 0.55, innerMin: maxR * 0.4, outer: maxR * 0.62, spacing: 46, minSpacing: 20,
187
+ });
188
+ const bridgePos = bridges.map((b, i) => {
189
+ const p = bridgeLayout.points[i];
190
+ return { ...b, x: cx + p.x, y: cy + p.y, angle: p.angle, clusterSize: (bridgeMap[b.id] || []).length };
191
+ });
192
+
193
+ // Touch rotary dial on the overview: drag a finger round the circle to pick a bridge.
194
+ const bridgeAnglesRef = useRef([]);
195
+ useEffect(() => {
196
+ bridgeAnglesRef.current = bridgePos.map((b) => ({ id: b.id, angle: b.angle }));
197
+ });
45
198
  const lastTouchRef = useRef(0);
46
199
 
47
200
  useEffect(() => {
48
201
  const el = containerRef.current;
49
- if (!el) return;
202
+ if (!el || focused) return undefined;
50
203
 
51
204
  const findNearest = (touchX, touchY) => {
52
205
  const rect = el.getBoundingClientRect();
53
- const cx = dims.w / 2;
54
- const cy = dims.h / 2;
55
206
  const tx = touchX - rect.left;
56
207
  const ty = touchY - rect.top;
57
- const touchAngle = Math.atan2(ty - cy, tx - cx);
208
+ const touchAngle = Math.atan2(ty - dims.h / 2, tx - dims.w / 2);
58
209
  let nearest = null;
59
210
  let nearestDist = Infinity;
60
211
  bridgeAnglesRef.current.forEach(b => {
61
- let diff = Math.abs(touchAngle - b.angle);
212
+ let diff = Math.abs(touchAngle - b.angle) % (Math.PI * 2);
62
213
  if (diff > Math.PI) diff = Math.PI * 2 - diff;
63
214
  if (diff < nearestDist) { nearestDist = diff; nearest = b.id; }
64
215
  });
@@ -66,7 +217,6 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
66
217
  };
67
218
 
68
219
  const onTouchStart = (e) => {
69
- setIsTouching(true);
70
220
  if (e.touches[0]) {
71
221
  const n = findNearest(e.touches[0].clientX, e.touches[0].clientY);
72
222
  if (n) setHovered(n);
@@ -83,441 +233,56 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
83
233
  if (n) setHovered(n);
84
234
  }
85
235
  };
86
- const onTouchEnd = () => {
87
- setIsTouching(false);
88
- };
89
236
 
90
237
  el.addEventListener('touchstart', onTouchStart, { passive: false });
91
238
  el.addEventListener('touchmove', onTouchMove, { passive: false });
92
- el.addEventListener('touchend', onTouchEnd);
93
- return () => { el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchmove', onTouchMove); el.removeEventListener('touchend', onTouchEnd); };
94
- }, [dims, zoom]);
95
-
96
- // Build bridge map
97
- const bridgeMap = {};
98
- degree2.forEach(d2 => {
99
- if (!d2.source_connection_id) return;
100
- if (!bridgeMap[d2.source_connection_id]) bridgeMap[d2.source_connection_id] = [];
101
- bridgeMap[d2.source_connection_id].push(d2);
102
- });
103
-
104
- const bridges = connections.filter(c => bridgeMap[c.id] && bridgeMap[c.id].length > 0)
105
- .sort((a, b) => (bridgeMap[b.id]?.length || 0) - (bridgeMap[a.id]?.length || 0));
106
-
107
- const cx = dims.w / 2;
108
- const cy = dims.h / 2;
109
- const maxR = Math.min(cx, cy) - 30;
110
-
111
- // === FOCUSED VIEW: one bridge selected, show its D2 paths ===
112
- if (focusedBridge) {
113
- const bridge = bridges.find(b => b.id === focusedBridge);
114
- if (!bridge) { setFocusedBridge(null); return null; }
115
- const cluster = (bridgeMap[bridge.id] || [])
116
- .sort((a, b) => {
117
- const to = { S: 0, A: 1, B: 2, C: 3, D: 4 };
118
- return (to[a.tier] || 9) - (to[b.tier] || 9) || (parseFloat(b.power_score) || 0) - (parseFloat(a.power_score) || 0);
119
- });
120
-
121
- const bridgeR = maxR * 0.15;
122
- const d2R = maxR * 0.7;
123
-
124
- // Touch handler for D2 ring in focused view
125
- const handleD2Touch = (touchX, touchY) => {
126
- if (!containerRef.current) return;
127
- const rect = containerRef.current.getBoundingClientRect();
128
- const tx = touchX - rect.left;
129
- const ty = touchY - rect.top;
130
- const touchAngle = Math.atan2(ty - cy, tx - cx);
131
- let nearest = null;
132
- let nearestDist = Infinity;
133
- cluster.forEach((d2, i) => {
134
- const angle = (i / cluster.length) * Math.PI * 2 - Math.PI / 2;
135
- let diff = Math.abs(touchAngle - angle);
136
- if (diff > Math.PI) diff = Math.PI * 2 - diff;
137
- if (diff < nearestDist) { nearestDist = diff; nearest = d2.id; }
138
- });
139
- if (nearest) setHovered(nearest);
140
- };
239
+ return () => { el.removeEventListener('touchstart', onTouchStart); el.removeEventListener('touchmove', onTouchMove); };
240
+ }, [dims, zoom, focused]);
141
241
 
242
+ // === ONE CIRCLE: a bridge's, or anyone's in it, opened in place ===
243
+ if (focused) {
142
244
  return (
143
- <div ref={containerRef} style={{ flex: 1, background: '#0a0a1a', position: 'relative', overflow: 'hidden' }}
144
- onTouchStart={(e) => { if (e.touches[0]) handleD2Touch(e.touches[0].clientX, e.touches[0].clientY); }}
145
- onTouchMove={(e) => { e.preventDefault(); if (e.touches[0]) handleD2Touch(e.touches[0].clientX, e.touches[0].clientY); }}
146
- onTouchEnd={() => {
147
- // Tap to select in sidebar
148
- if (hovered) { const d2 = cluster.find(d => d.id === hovered); if (d2) onSelect && onSelect(d2); }
149
- }}>
150
- <svg width={dims.w} height={dims.h}>
151
- <defs>
152
- <filter id="focusGlow"><feGaussianBlur stdDeviation="4" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
153
- </defs>
154
-
155
- {/* D2 ring guide */}
156
- <circle cx={cx} cy={cy} r={d2R} fill="none" stroke={`${DEGREE_COLORS[2]}08`} strokeWidth={1} />
157
- {/* D3 ghost ring — outer ring around D2 */}
158
- {hovered && <circle cx={cx} cy={cy} r={maxR * 0.93} fill="none" stroke={`${DEGREE_COLORS[3]}10`} strokeWidth={1} strokeDasharray="3,5" />}
159
-
160
- {/* Lines: bridge → each D2 */}
161
- {cluster.map((d2, i) => {
162
- const angle = (i / cluster.length) * Math.PI * 2 - Math.PI / 2;
163
- const x2 = cx + d2R * Math.cos(angle);
164
- const y2 = cy + d2R * Math.sin(angle);
165
- const isHov = hovered === d2.id;
166
- return (
167
- <line key={'fl-' + i} x1={cx} y1={cy} x2={x2} y2={y2}
168
- stroke={TIER_COLORS[d2.tier] || '#555'}
169
- strokeWidth={isHov ? 2 : 0.5}
170
- strokeOpacity={isHov ? 0.8 : 0.12} />
171
- );
172
- })}
173
-
174
- {/* D2 nodes — check if any are D3 ready (promoted/accepted) */}
175
- {cluster.map((d2, i) => {
176
- const angle = (i / cluster.length) * Math.PI * 2 - Math.PI / 2;
177
- const x = cx + d2R * Math.cos(angle);
178
- const y = cy + d2R * Math.sin(angle);
179
- const isHov = hovered === d2.id;
180
- const nodeR = d2.tier === 'S' ? 7 : d2.tier === 'A' ? 6 : d2.tier === 'B' ? 5 : 4;
181
- // Check if this D2 person is D3 ready (accepted, now D1, can be bridged)
182
- const isD3Ready = d2.outreach_status === 'accepted' ||
183
- connections.some(c => c.profile_url === d2.profile_url && c.outreach_status === 'accepted');
184
- const d3GhostR = maxR * 0.93;
185
-
186
- return (
187
- <g key={'fn-' + i}
188
- onClick={() => onSelect && onSelect(d2)}
189
- onMouseEnter={() => setHovered(d2.id)}
190
- onMouseLeave={() => setHovered(null)}
191
- style={{ cursor: 'pointer' }}>
192
-
193
- {/* D3 ready: gradient color loop + mystery dots */}
194
- {isD3Ready && (
195
- <>
196
- {/* Breathing glow */}
197
- <circle cx={x} cy={y} r={nodeR + 6} fill="none" stroke={DEGREE_COLORS[3]} strokeWidth={1.5} strokeOpacity={0.2}>
198
- <animate attributeName="r" values={`${nodeR+4};${nodeR+9};${nodeR+4}`} dur="2s" repeatCount="indefinite" />
199
- <animate attributeName="stroke-opacity" values="0.1;0.4;0.1" dur="2s" repeatCount="indefinite" />
200
- </circle>
201
- {/* Color gradient loop on the node */}
202
- <circle cx={x} cy={y} r={nodeR + 1}
203
- fill={DEGREE_COLORS[3]} fillOpacity={0.3} stroke="none">
204
- <animate attributeName="fill" values="#3498DB;#FFD700;#FF6B35;#9B59B6;#3498DB" dur="4s" repeatCount="indefinite" />
205
- <animate attributeName="fillOpacity" values="0.2;0.4;0.2" dur="2s" repeatCount="indefinite" />
206
- </circle>
207
- {/* D3 mystery lines + dots */}
208
- {Array.from({ length: 3 }, (_, gi) => {
209
- const gSpread = Math.PI * 0.25;
210
- const gAngle = angle - gSpread / 2 + (gi / 2) * gSpread;
211
- const gx = cx + d3GhostR * Math.cos(gAngle);
212
- const gy = cy + d3GhostR * Math.sin(gAngle);
213
- return (
214
- <g key={'d3g-' + gi}>
215
- <line x1={x} y1={y} x2={gx} y2={gy}
216
- stroke={DEGREE_COLORS[3]} strokeWidth={0.6} strokeOpacity={0.1} strokeDasharray="3,4">
217
- <animate attributeName="stroke-opacity" values="0.05;0.2;0.05" dur="3s" repeatCount="indefinite" />
218
- </line>
219
- <circle cx={gx} cy={gy} r={4}
220
- fill={DEGREE_COLORS[3]} fillOpacity={0.2} stroke={DEGREE_COLORS[3]} strokeWidth={0.8} strokeOpacity={0.2}>
221
- <animate attributeName="fillOpacity" values="0.1;0.35;0.1" dur="2.5s" repeatCount="indefinite" />
222
- <animate attributeName="r" values="3;5;3" dur="3s" repeatCount="indefinite" />
223
- </circle>
224
- </g>
225
- );
226
- })}
227
- </>
228
- )}
229
-
230
- {/* Regular glow for S/A */}
231
- {!isD3Ready && (d2.tier === 'S' || d2.tier === 'A') && (
232
- <circle cx={x} cy={y} r={nodeR + 4} fill={`${TIER_COLORS[d2.tier]}12`} />
233
- )}
234
-
235
- {/* Main node */}
236
- <circle cx={x} cy={y} r={isHov ? nodeR + 2 : nodeR}
237
- fill={isD3Ready ? DEGREE_COLORS[3] : (TIER_COLORS[d2.tier] || '#555')}
238
- fillOpacity={isHov ? 1 : (isD3Ready ? 0.7 : 0.5)}
239
- stroke={isHov ? '#fff' : (isD3Ready ? DEGREE_COLORS[3] : 'none')}
240
- strokeWidth={isHov ? 2 : (isD3Ready ? 1.5 : 0)}>
241
- {isD3Ready && !isHov && (
242
- <animate attributeName="fill" values="#3498DB;#FFD700;#FF6B35;#3498DB" dur="4s" repeatCount="indefinite" />
243
- )}
244
- </circle>
245
-
246
- {/* Hover tooltip */}
247
- {isHov && (
248
- <g>
249
- <rect x={x - 70} y={y - 36} width={140} height={28} rx={6}
250
- fill="rgba(0,0,0,0.92)" stroke={isD3Ready ? DEGREE_COLORS[3] : TIER_COLORS[d2.tier]} strokeWidth={0.5} />
251
- <text x={x} y={y - 20} textAnchor="middle" fill="#fff" fontSize={10} fontWeight={700}>
252
- {d2.name?.substring(0, 22)}
253
- </text>
254
- <text x={x} y={y - 10} textAnchor="middle" fill={isD3Ready ? DEGREE_COLORS[3] : TIER_COLORS[d2.tier]} fontSize={8}>
255
- {isD3Ready ? '⚡ Bridge to unlock D3' : `${d2.tier} · ${parseFloat(d2.power_score || 0).toFixed(1)}`}
256
- </text>
257
- </g>
258
- )}
259
-
260
- {/* Name for S-tier or D3 ready */}
261
- {(d2.tier === 'S' || isD3Ready) && !isHov && (
262
- <text x={x} y={y + nodeR + 12} textAnchor="middle"
263
- fill={isD3Ready ? DEGREE_COLORS[3] : '#aaa'} fontSize={8}
264
- fontWeight={isD3Ready ? 700 : 400}>
265
- {d2.name?.split(' ')[0]}
266
- </text>
267
- )}
268
- </g>
269
- );
270
- })}
271
-
272
- {/* D3 ghost nodes — on the outer D3 ring, lines from hovered D2 */}
273
- {hovered && (() => {
274
- const hovIdx = cluster.findIndex(d => d.id === hovered);
275
- if (hovIdx === -1) return null;
276
- const hovAngle = (hovIdx / cluster.length) * Math.PI * 2 - Math.PI / 2;
277
- const hovX = cx + d2R * Math.cos(hovAngle);
278
- const hovY = cy + d2R * Math.sin(hovAngle);
279
- const d3R = maxR * 0.93;
280
- const ghostCount = 5;
281
- const spread = Math.PI * 0.35;
282
- return Array.from({ length: ghostCount }, (_, j) => {
283
- const gAngle = hovAngle - spread / 2 + (j / (ghostCount - 1)) * spread;
284
- const gx = cx + d3R * Math.cos(gAngle);
285
- const gy = cy + d3R * Math.sin(gAngle);
286
- return (
287
- <g key={'g3-' + j}>
288
- <line x1={hovX} y1={hovY} x2={gx} y2={gy}
289
- stroke={DEGREE_COLORS[3]} strokeWidth={0.8} strokeOpacity={0.2} strokeDasharray="3,3" />
290
- <circle cx={gx} cy={gy} r={9}
291
- fill="rgba(52,152,219,0.06)" stroke={DEGREE_COLORS[3]} strokeWidth={1.5} strokeOpacity={0.3} strokeDasharray="2,2" />
292
- <text x={gx} y={gy + 4} textAnchor="middle" fill={DEGREE_COLORS[3]} fontSize={12} fontWeight={800} fillOpacity={0.5}>?</text>
293
- </g>
294
- );
295
- });
296
- })()}
297
-
298
- {/* Center: bridge node */}
299
- <circle cx={cx} cy={cy} r={28} fill="#0a0a1a" stroke={TIER_COLORS[bridge.tier]} strokeWidth={3} filter="url(#focusGlow)" />
300
- {bridge.profile_image_url ? (
301
- <>
302
- <clipPath id="focus-clip"><circle cx={cx} cy={cy} r={24} /></clipPath>
303
- <image href={bridge.profile_image_url} x={cx - 24} y={cy - 24} width={48} height={48} clipPath="url(#focus-clip)" />
304
- </>
305
- ) : (
306
- <text x={cx} y={cy + 5} textAnchor="middle" fill={TIER_COLORS[bridge.tier]} fontSize={16} fontWeight={800}>
307
- {bridge.name?.charAt(0)}
308
- </text>
309
- )}
310
- <text x={cx} y={cy + 38} textAnchor="middle" fill="#fff" fontSize={11} fontWeight={700}>{bridge.name}</text>
311
- <text x={cx} y={cy + 50} textAnchor="middle" fill="#888" fontSize={9}>{cluster.length} D2 connections</text>
312
- </svg>
313
-
314
- {/* Back button */}
315
- <button onClick={() => setFocusedBridge(null)} style={{
316
- position: 'absolute', top: 16, left: 16,
317
- padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
318
- background: 'rgba(255,255,255,0.08)', color: '#aaa', fontSize: 12, fontWeight: 600,
319
- }}>← All Bridges</button>
320
-
321
- {/* Tier breakdown */}
322
- <div style={{
323
- position: 'absolute', bottom: 20, left: 20,
324
- background: 'rgba(0,0,0,0.75)', borderRadius: 10, padding: '10px 14px',
325
- backdropFilter: 'blur(8px)',
326
- }}>
327
- <div style={{ fontSize: 9, fontWeight: 700, color: '#666', letterSpacing: 1, marginBottom: 6 }}>CLUSTER BREAKDOWN</div>
328
- {['S', 'A', 'B', 'C', 'D'].map(t => {
329
- const cnt = cluster.filter(d => d.tier === t).length;
330
- if (cnt === 0) return null;
331
- return (
332
- <div key={t} style={{ display: 'flex', alignItems: 'center', gap: 6, marginBottom: 3 }}>
333
- <div style={{ width: 6, height: 6, borderRadius: '50%', background: TIER_COLORS[t] }} />
334
- <span style={{ fontSize: 10, color: TIER_COLORS[t], fontWeight: 600, width: 40 }}>{t}-Tier</span>
335
- <span style={{ fontSize: 10, color: '#888' }}>{cnt}</span>
336
- </div>
337
- );
338
- })}
339
- </div>
245
+ <div ref={containerRef} style={{ flex: 1, background: '#0a0a1a', position: 'relative', overflow: 'hidden' }}>
246
+ <CircleFocus
247
+ key={trail.map((r) => r.id).join('>')}
248
+ trail={trail} index={index} reach={reach} dims={dims} requests={requests}
249
+ scanningId={scanningId} still={still} canScan={canScan}
250
+ onOpen={(id) => setPath(trail.map((r) => r.id).concat(id))}
251
+ onBack={(depth) => setPath(trail.slice(0, depth).map((r) => r.id))}
252
+ onSelect={onSelect}
253
+ />
340
254
  </div>
341
255
  );
342
256
  }
343
257
 
344
- // === OVERVIEW: all bridges, click to focus ===
345
- const r1 = maxR * 0.55;
346
- const promotedR = maxR * 0.82;
347
-
348
- const bridgePos = bridges.map((b, i) => {
349
- const angle = (i / bridges.length) * Math.PI * 2 - Math.PI / 2;
350
- return { ...b, x: cx + r1 * Math.cos(angle), y: cy + r1 * Math.sin(angle), angle, clusterSize: (bridgeMap[b.id] || []).length };
351
- });
352
-
353
- // Promoted D2→D1: accepted connections without clusters yet (D3 candidates)
354
- const promoted = connections.filter(c => c.outreach_status === 'accepted' && !bridgeMap[c.id]);
355
- const promotedPos = promoted.map((p, i) => {
356
- const angle = (i / Math.max(promoted.length, 1)) * Math.PI * 2 - Math.PI / 2;
357
- return { ...p, x: cx + promotedR * Math.cos(angle), y: cy + promotedR * Math.sin(angle), angle };
358
- });
359
-
258
+ // === OVERVIEW: all bridges, click to open one ===
360
259
  const totalD2 = degree2.length;
361
260
  const d2S = degree2.filter(d => d.tier === 'S').length;
362
261
  const d2A = degree2.filter(d => d.tier === 'A').length;
262
+ const hovBridge = hovered ? bridgePos.find(b => b.id === hovered) : null;
363
263
 
364
264
  return (
365
265
  <div ref={containerRef} style={{ flex: 1, background: '#0a0a1a', position: 'relative', overflow: 'hidden' }}>
366
266
  <svg width={dims.w} height={dims.h}>
367
267
  <g transform={`translate(${cx * (1 - zoom)}, ${cy * (1 - zoom)}) scale(${zoom})`}>
368
268
  {/* Ring guide */}
369
- <circle cx={cx} cy={cy} r={r1} fill="none" stroke={`${DEGREE_COLORS[1]}10`} strokeWidth={1} />
370
-
371
- {/* D2 outer ring guide — appears on hover */}
372
- {hovered && <circle cx={cx} cy={cy} r={maxR * 0.88} fill="none" stroke={`${DEGREE_COLORS[2]}08`} strokeWidth={1} />}
269
+ <circle cx={cx} cy={cy} r={maxR * 0.55} fill="none" stroke={`${DEGREE_COLORS[1]}10`} strokeWidth={1} />
373
270
 
374
- {/* D2 preview dots + D3 ghosts — full chain on bridge hover */}
375
- {hovered && (() => {
376
- const hovBridge = bridgePos.find(b => b.id === hovered);
377
- if (!hovBridge) return null;
378
- const cluster = (bridgeMap[hovBridge.id] || []).filter(d => d.tier === 'S' || d.tier === 'A' || d.tier === 'B');
379
- const d2Ring = maxR * 0.75;
380
- const d3Ring = maxR * 0.95;
381
- const spread = Math.min(Math.PI * 0.8, (cluster.length / 40) * Math.PI);
382
- const startAngle = hovBridge.angle - spread / 2;
271
+ {/* Their circle, previewed on hover, in a wedge that grows rows as it fills */}
272
+ {hovBridge && (
273
+ <CirclePreview bridge={hovBridge} members={membersOf(hovBridge, index)} reach={reach}
274
+ cx={cx} cy={cy} maxR={maxR} still={still} />
275
+ )}
383
276
 
384
- return (
385
- <g>
386
- {/* D2 ring guide */}
387
- <circle cx={cx} cy={cy} r={d2Ring} fill="none" stroke={`${DEGREE_COLORS[2]}06`} strokeWidth={1} />
388
- {/* D3 ring guide */}
389
- <circle cx={cx} cy={cy} r={d3Ring} fill="none" stroke={`${DEGREE_COLORS[3]}05`} strokeWidth={1} strokeDasharray="3,5" />
390
-
391
- {cluster.map((d2, j) => {
392
- const t = cluster.length > 1 ? j / (cluster.length - 1) : 0.5;
393
- const angle = startAngle + t * spread;
394
- const x2 = cx + d2Ring * Math.cos(angle);
395
- const y2 = cy + d2Ring * Math.sin(angle);
396
- const nr = d2.tier === 'S' ? 5 : d2.tier === 'A' ? 4 : 3;
397
-
398
- // D3 ghost for S/A tier D2 people (they'd have the best D3 networks)
399
- const showD3 = d2.tier === 'S' || d2.tier === 'A';
400
- const d3x = cx + d3Ring * Math.cos(angle);
401
- const d3y = cy + d3Ring * Math.sin(angle);
402
-
403
- return (
404
- <g key={'pv-' + j}>
405
- {/* Bridge → D2 line */}
406
- <line x1={hovBridge.x} y1={hovBridge.y} x2={x2} y2={y2}
407
- stroke={TIER_COLORS[d2.tier]} strokeWidth={0.5} strokeOpacity={0.15} />
408
- {/* D2 node */}
409
- <circle cx={x2} cy={y2} r={nr}
410
- fill={TIER_COLORS[d2.tier]} fillOpacity={0.45}
411
- stroke={TIER_COLORS[d2.tier]} strokeWidth={0.5} strokeOpacity={0.25} />
412
- {d2.tier === 'S' && (
413
- <text x={x2} y={y2 + nr + 10} textAnchor="middle" fill="#aaa" fontSize={7}>{d2.name?.split(' ')[0]}</text>
414
- )}
415
- {/* D3 ghost extending from D2 */}
416
- {showD3 && (
417
- <>
418
- <line x1={x2} y1={y2} x2={d3x} y2={d3y}
419
- stroke={DEGREE_COLORS[3]} strokeWidth={0.5} strokeOpacity={0.12} strokeDasharray="2,3" />
420
- <circle cx={d3x} cy={d3y} r={6}
421
- fill="rgba(52,152,219,0.04)" stroke={DEGREE_COLORS[3]}
422
- strokeWidth={1} strokeOpacity={0.2} strokeDasharray="2,2" />
423
- <text x={d3x} y={d3y + 3.5} textAnchor="middle" fill={DEGREE_COLORS[3]}
424
- fontSize={9} fontWeight={700} fillOpacity={0.35}>?</text>
425
- </>
426
- )}
427
- </g>
428
- );
429
- })}
430
- </g>
431
- );
432
- })()}
433
-
434
- {/* Promoted D2→D1 "D3 ready" nodes — ONLY visible when hovering their parent bridge */}
435
- {hovered && (() => {
436
- // Find promoted people connected to the hovered bridge
437
- const hovBridge = bridgePos.find(b => b.id === hovered);
438
- if (!hovBridge) return null;
439
- const bridgePromoted = promoted.filter(p => {
440
- // Check if this promoted person was in this bridge's D2 cluster
441
- const d2Record = degree2.find(d => d.profile_url === p.profile_url && d.source_connection_id === hovBridge.id);
442
- return !!d2Record;
443
- });
444
- if (bridgePromoted.length === 0) return null;
445
-
446
- const d3ReadyR = maxR * 0.75; // Same ring as D2 dots
447
- const d3GhostR = maxR * 0.95;
448
-
449
- return bridgePromoted.map((p, pi) => {
450
- // Position at the END of the D2 spread (after the regular D2 dots)
451
- const cluster = (bridgeMap[hovBridge.id] || []).filter(d => d.tier === 'S' || d.tier === 'A' || d.tier === 'B');
452
- const dSpread = Math.min(Math.PI * 0.8, (cluster.length / 40) * Math.PI);
453
- const pAngle = hovBridge.angle + dSpread / 2 + 0.15 + pi * 0.25;
454
- const px = cx + d3ReadyR * Math.cos(pAngle);
455
- const py = cy + d3ReadyR * Math.sin(pAngle);
456
- const d3GhostR = maxR * 0.95;
457
-
458
- return (
459
- <g key={'promo-' + pi}>
460
- {/* Line from bridge → promoted (breathing) */}
461
- <line x1={hovBridge.x} y1={hovBridge.y} x2={px} y2={py}
462
- stroke={DEGREE_COLORS[3]} strokeWidth={1.5} strokeOpacity={0.3}>
463
- <animate attributeName="stroke-opacity" values="0.15;0.5;0.15" dur="2s" repeatCount="indefinite" />
464
- </line>
465
-
466
- {/* Breathing glow */}
467
- <circle cx={px} cy={py} r={16} fill="none" stroke={DEGREE_COLORS[3]} strokeWidth={1} strokeOpacity={0.1}>
468
- <animate attributeName="r" values="14;19;14" dur="2s" repeatCount="indefinite" />
469
- <animate attributeName="stroke-opacity" values="0.05;0.25;0.05" dur="2s" repeatCount="indefinite" />
470
- </circle>
471
-
472
- {/* Node — color shifts between blue and gold */}
473
- <circle cx={px} cy={py} r={12}
474
- fill={DEGREE_COLORS[3]} fillOpacity={0.3}
475
- stroke={DEGREE_COLORS[3]} strokeWidth={2}
476
- onClick={() => { onSelect && onSelect(p); setFocusedBridge(hovBridge.id); }}
477
- style={{ cursor: 'pointer' }}>
478
- <animate attributeName="fill" values="rgba(52,152,219,0.3);rgba(255,215,0,0.3);rgba(52,152,219,0.3)" dur="3s" repeatCount="indefinite" />
479
- <animate attributeName="stroke" values="#3498DB;#FFD700;#3498DB" dur="3s" repeatCount="indefinite" />
480
- </circle>
481
-
482
- {/* Name */}
483
- <text x={px} y={py + 20} textAnchor="middle" fill="#fff" fontSize={9} fontWeight={700}>
484
- {p.name?.split(' ')[0]}
485
- </text>
486
-
487
- {/* D3 preview — solid dots (not question marks) */}
488
- {Array.from({ length: 4 }, (_, gi) => {
489
- const gSpread = Math.PI * 0.3;
490
- const gAngle = pAngle - gSpread / 2 + (gi / 3) * gSpread;
491
- const gx = cx + d3GhostR * Math.cos(gAngle);
492
- const gy = cy + d3GhostR * Math.sin(gAngle);
493
- return (
494
- <g key={'pg-' + gi}>
495
- <line x1={px} y1={py} x2={gx} y2={gy}
496
- stroke={DEGREE_COLORS[3]} strokeWidth={0.5} strokeOpacity={0.12}>
497
- <animate attributeName="stroke-opacity" values="0.06;0.18;0.06" dur="2.5s" repeatCount="indefinite" />
498
- </line>
499
- <circle cx={gx} cy={gy} r={5}
500
- fill={DEGREE_COLORS[3]} fillOpacity={0.25}
501
- stroke={DEGREE_COLORS[3]} strokeWidth={1} strokeOpacity={0.2}>
502
- <animate attributeName="fillOpacity" values="0.15;0.35;0.15" dur="2s" repeatCount="indefinite" />
503
- </circle>
504
- </g>
505
- );
506
- })}
507
- </g>
508
- );
509
- });
510
- })()}
511
-
512
- {/* Bridge nodes — hover to preview D2, click to focus */}
277
+ {/* Bridge nodes — hover to preview their circle, click to open it */}
513
278
  {bridgePos.map(b => {
514
279
  const isHov = hovered === b.id;
515
280
  const sCount = (bridgeMap[b.id] || []).filter(d => d.tier === 'S').length;
516
281
  const aCount = (bridgeMap[b.id] || []).filter(d => d.tier === 'A').length;
517
- const hasD3 = promoted.some(p => degree2.some(d => d.profile_url === p.profile_url && d.source_connection_id === b.id));
282
+ const ready = readyCount.get(b.id) || 0;
518
283
  return (
519
284
  <g key={'b-' + b.id}
520
- onClick={() => setFocusedBridge(b.id)}
285
+ onClick={() => { setHovered(null); setPath([b.id]); }}
521
286
  onMouseEnter={() => setHovered(b.id)}
522
287
  onMouseLeave={() => setHovered(null)}
523
288
  style={{ cursor: 'pointer' }}>
@@ -525,34 +290,34 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
525
290
  <circle cx={b.x} cy={b.y} r={isHov ? 20 : 16}
526
291
  fill={`${TIER_COLORS[b.tier]}08`}
527
292
  stroke={`${TIER_COLORS[b.tier]}${isHov ? '60' : '25'}`} strokeWidth={1} />
528
- {/* D3 ready ring — color cycling animation */}
529
- {hasD3 && (
530
- <circle cx={b.x} cy={b.y} r={isHov ? 22 : 18}
531
- fill="none" strokeWidth={2} strokeOpacity={0.6}>
532
- <animate attributeName="stroke" values="#3498DB;#FFD700;#FF6B35;#9B59B6;#3498DB" dur="4s" repeatCount="indefinite" />
533
- </circle>
534
- )}
535
293
  {/* Node */}
536
294
  <circle cx={b.x} cy={b.y} r={isHov ? 13 : 11}
537
- fill={b.profile_image_url ? '#1a1a2e' : TIER_COLORS[b.tier]}
538
- stroke={isHov ? '#fff' : (hasD3 ? '#3498DB' : TIER_COLORS[b.tier])}
539
- strokeWidth={isHov ? 2.5 : 2}>
540
- {hasD3 && !isHov && (
541
- <animate attributeName="stroke" values="#3498DB;#FFD700;#FF6B35;#9B59B6;#3498DB" dur="4s" repeatCount="indefinite" />
542
- )}
543
- </circle>
295
+ fill={localPhoto(b.profile_image_url) ? '#1a1a2e' : TIER_COLORS[b.tier]}
296
+ stroke={isHov ? '#fff' : TIER_COLORS[b.tier]}
297
+ strokeWidth={isHov ? 2.5 : 2} />
544
298
  {/* Photo */}
545
- {b.profile_image_url && (
299
+ {localPhoto(b.profile_image_url) && (
546
300
  <>
547
301
  <clipPath id={'bc-' + b.id}><circle cx={b.x} cy={b.y} r={isHov ? 11 : 9} /></clipPath>
548
- <image href={b.profile_image_url} x={b.x - (isHov ? 11 : 9)} y={b.y - (isHov ? 11 : 9)}
302
+ <image href={localPhoto(b.profile_image_url)} x={b.x - (isHov ? 11 : 9)} y={b.y - (isHov ? 11 : 9)}
549
303
  width={isHov ? 22 : 18} height={isHov ? 22 : 18} clipPath={`url(#bc-${b.id})`} />
550
304
  </>
551
305
  )}
552
- {!b.profile_image_url && (
306
+ {!localPhoto(b.profile_image_url) && (
553
307
  <text x={b.x} y={b.y + 4} textAnchor="middle" fill={b.tier === 'S' ? '#000' : '#fff'}
554
308
  fontSize={11} fontWeight={700}>{b.name?.charAt(0)}</text>
555
309
  )}
310
+ {/* People you reached through their circle, ready for a scan of their own */}
311
+ {ready > 0 && (
312
+ <g>
313
+ <title>{`${ready} you reached through ${firstName(b)}’s circle ${ready === 1 ? 'is' : 'are'} ready for a scan`}</title>
314
+ <rect x={b.x + 7} y={b.y - 21} width={ready > 9 ? 40 : 34} height={11} rx={5.5}
315
+ fill="rgba(0,255,136,0.16)" stroke={GREEN} strokeWidth={0.6} />
316
+ <text x={b.x + 7 + (ready > 9 ? 20 : 17)} y={b.y - 13} textAnchor="middle" fill={GREEN} fontSize={7} fontWeight={800}>
317
+ {ready} ready
318
+ </text>
319
+ </g>
320
+ )}
556
321
  {/* Name + count */}
557
322
  <text x={b.x} y={b.y + (isHov ? 24 : 22)} textAnchor="middle" fill="#fff" fontSize={9} fontWeight={600}>
558
323
  {b.name?.split(' ')[0]}
@@ -572,29 +337,12 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
572
337
  </g>
573
338
  </svg>
574
339
 
575
- {/* Zoom controls */}
576
- <div style={{
577
- position: 'absolute', bottom: 20, right: 20, zIndex: 20,
578
- display: 'flex', flexDirection: 'column', gap: 4,
579
- }}>
580
- <button onClick={() => setZoom(z => Math.min(3, z + 0.3))} style={{
581
- width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer',
582
- background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: 18, fontWeight: 700,
583
- display: 'flex', alignItems: 'center', justifyContent: 'center',
584
- backdropFilter: 'blur(8px)',
585
- }}>+</button>
586
- <button onClick={() => setZoom(1.3)} style={{
587
- width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer',
588
- background: 'rgba(255,255,255,0.06)', color: '#666', fontSize: 10, fontWeight: 700,
589
- display: 'flex', alignItems: 'center', justifyContent: 'center',
590
- }}>⊙</button>
591
- <button onClick={() => setZoom(z => Math.max(0.4, z - 0.3))} style={{
592
- width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer',
593
- background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: 18, fontWeight: 700,
594
- display: 'flex', alignItems: 'center', justifyContent: 'center',
595
- backdropFilter: 'blur(8px)',
596
- }}>−</button>
597
- </div>
340
+ {/* Zoom controls, clear of the Galaxy switch below them */}
341
+ <ZoomButtons
342
+ onIn={() => setZoom(z => Math.min(3, z + 0.3))}
343
+ onReset={() => setZoom(1.3)}
344
+ onOut={() => setZoom(z => Math.max(0.4, z - 0.3))}
345
+ />
598
346
 
599
347
  {/* Depth tracker */}
600
348
  <div style={{
@@ -622,10 +370,452 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
622
370
  ))}
623
371
  </div>
624
372
  <div style={{ fontSize: 9, color: '#555', marginTop: 8 }}>
625
- {bridges.length} bridges · {d2S} S + {d2A} A at D2
373
+ {bridges.length} bridges · {d2S} S + {d2A} A at 2nd degree
626
374
  </div>
627
- <div style={{ fontSize: 8, color: '#444', marginTop: 4 }}>Click a bridge to see their D2 paths</div>
375
+ <div style={{ fontSize: 8, color: '#444', marginTop: 4 }}>Click a bridge to open their circle, then anyone in it to open theirs</div>
628
376
  </div>
629
377
  </div>
630
378
  );
631
379
  }
380
+
381
+ /** A bridge's circle on hover: the people you reached through it first, then their S, A and B. */
382
+ function CirclePreview({ bridge, members, reach, cx, cy, maxR, still }) {
383
+ const shown = members.filter((m) => m.degree === 1 || m.tier === 'S' || m.tier === 'A' || m.tier === 'B');
384
+ // Beyond the ring of bridges, inside the window at the overview's zoom.
385
+ const inner = maxR * 0.66;
386
+ const sweep = Math.min(Math.PI * 0.9, Math.max(Math.PI * 0.2, (shown.length * 11) / (maxR * 0.75)));
387
+ const layout = ringLayout(shown.length, {
388
+ inner, outer: maxR * 0.8, spacing: 11, minSpacing: 3.5, start: bridge.angle - sweep / 2, sweep,
389
+ });
390
+ return (
391
+ <g>
392
+ <circle cx={cx} cy={cy} r={inner} fill="none" stroke={`${DEGREE_COLORS[2]}06`} strokeWidth={1} />
393
+ {shown.map((d2, j) => {
394
+ const p = layout.points[j];
395
+ const x2 = cx + p.x;
396
+ const y2 = cy + p.y;
397
+ const state = reachState(d2, reach);
398
+ const nr = Math.min(dotRadius(layout.spacing, d2.tier), 5) * (state ? 1.3 : 1);
399
+ const color = state === 'hidden' ? HIDDEN : TIER_COLORS[d2.tier] || '#555';
400
+ return (
401
+ <g key={'pv-' + d2.id}>
402
+ {(state || shown.length <= 40) && (
403
+ <line x1={bridge.x} y1={bridge.y} x2={x2} y2={y2}
404
+ stroke={state ? GREEN : color} strokeWidth={0.5} strokeOpacity={state ? 0.35 : 0.15} />
405
+ )}
406
+ {state === 'ready' && <Halo x={x2} y={y2} r={nr * 2.3} still={still} />}
407
+ <circle cx={x2} cy={y2} r={nr}
408
+ fill={color} fillOpacity={state ? 0.95 : 0.45}
409
+ stroke={state && state !== 'hidden' ? GREEN : color} strokeWidth={state ? 1 : 0.5} strokeOpacity={state ? 1 : 0.25} />
410
+ {(d2.tier === 'S' || state) && shown.length <= 60 && (
411
+ <text x={x2} y={y2 + nr + 9} textAnchor="middle" fill={state && state !== 'hidden' ? GREEN : '#aaa'} fontSize={7}>
412
+ {firstName(d2)}
413
+ </text>
414
+ )}
415
+ </g>
416
+ );
417
+ })}
418
+ </g>
419
+ );
420
+ }
421
+
422
+ /** The soft breathing halo on someone ready for a circle scan. Still when Reduce Motion is on. */
423
+ function Halo({ x, y, r, still }) {
424
+ return (
425
+ <circle cx={x} cy={y} r={r} fill={GREEN} fillOpacity={0.16} stroke={GREEN} strokeWidth={0.8} strokeOpacity={0.45} pointerEvents="none">
426
+ {!still && <animate attributeName="fill-opacity" values="0.06;0.24;0.06" dur="2.8s" repeatCount="indefinite" />}
427
+ {!still && <animate attributeName="stroke-opacity" values="0.25;0.7;0.25" dur="2.8s" repeatCount="indefinite" />}
428
+ </circle>
429
+ );
430
+ }
431
+
432
+ /** A small lock, drawn inside a greyed dot whose list is hidden. */
433
+ function Lock({ x, y, s }) {
434
+ return (
435
+ <g transform={`translate(${x} ${y}) scale(${s})`} pointerEvents="none">
436
+ <path d="M-1.9 -0.6 V-1.8 A1.9 1.9 0 0 1 1.9 -1.8 V-0.6" fill="none" stroke="#d6d6de" strokeWidth={0.9} />
437
+ <rect x={-2.6} y={-0.7} width={5.2} height={3.9} rx={0.8} fill="#d6d6de" />
438
+ </g>
439
+ );
440
+ }
441
+
442
+ function ZoomButtons({ onIn, onReset, onOut }) {
443
+ const round = (dim) => ({
444
+ width: 32, height: 32, borderRadius: '50%', border: 'none', cursor: 'pointer',
445
+ background: dim ? 'rgba(255,255,255,0.06)' : 'rgba(255,255,255,0.1)', color: dim ? '#666' : '#fff',
446
+ fontSize: dim ? 10 : 18, fontWeight: 700,
447
+ display: 'flex', alignItems: 'center', justifyContent: 'center', backdropFilter: 'blur(8px)',
448
+ });
449
+ return (
450
+ <div style={{ position: 'absolute', bottom: 64, right: 20, zIndex: 20, display: 'flex', flexDirection: 'column', gap: 4 }}>
451
+ <button type="button" onClick={onIn} aria-label="Zoom in" style={round(false)}>+</button>
452
+ <button type="button" onClick={onReset} aria-label="Fit to the window" style={round(true)}>⊙</button>
453
+ <button type="button" onClick={onOut} aria-label="Zoom out" style={round(false)}>−</button>
454
+ </div>
455
+ );
456
+ }
457
+
458
+ /**
459
+ * One person's circle, round them: a bridge's, or that of anyone in a circle
460
+ * opened before. `trail` is how you got here, the person last; their people
461
+ * are one degree further out along the chain than they are.
462
+ */
463
+ function CircleFocus({ trail, index, reach, dims, requests, scanningId, still, canScan, onOpen, onBack, onSelect }) {
464
+ const person = trail[trail.length - 1];
465
+ const depth = trail.length; // the person's degree along the chain: a bridge is 1
466
+ const members = useMemo(() => membersOf(person, index), [person, index]);
467
+ const [hover, setHover] = useState(null); // a member's index, 'center', or null
468
+ const [view, setView] = useState({ k: 1, x: 0, y: 0 });
469
+ const drag = useRef(null);
470
+ const svgRef = useRef(null);
471
+ const router = useRouter();
472
+
473
+ const cx = dims.w / 2;
474
+ const cy = dims.h / 2;
475
+ const maxR = Math.min(cx, cy) - 30;
476
+ const layout = useMemo(() => ringLayout(members.length, {
477
+ inner: maxR * 0.55, innerMin: 62, outer: maxR * 0.94,
478
+ spacing: Math.max(14, Math.min(24, maxR * 0.085)), minSpacing: 4,
479
+ }), [members.length, maxR]);
480
+
481
+ // What each dot is: someone you reached (ready, hidden, scanned) or a
482
+ // 2nd-degree row, maybe with a request out; and how big their own circle is.
483
+ const facts = useMemo(() => members.map((row) => {
484
+ const reached = reachState(row, reach);
485
+ return {
486
+ reached,
487
+ requested: !reached && row.degree === 2 && hasRequest(row, requests),
488
+ own: row.degree === 1 ? (index.circles.get(row.id)?.length || 0) + (index.introduced.get(row.id)?.length || 0) : 0,
489
+ };
490
+ }), [members, reach, requests, index]);
491
+ // Someone ready for a scan has nobody in their circle yet, so a click on them
492
+ // goes to the Scan page with them picked (backlog 2.4, pick 3). Everyone else
493
+ // opens in place, and so do they while their circle is scanned, to watch it fill in.
494
+ const goesToScan = (i) => canScan && facts[i].reached === 'ready' && members[i].id !== scanningId;
495
+
496
+ const k = view.k;
497
+ // The dots, drawn once per layout and zoom; hovering only draws on top of them.
498
+ const dots = useMemo(() => {
499
+ const few = members.length <= 60;
500
+ const roomy = layout.spacing * k >= 26;
501
+ return members.map((row, i) => {
502
+ const p = layout.points[i];
503
+ const f = facts[i];
504
+ const r = dotRadius(layout.spacing, row.tier) * (f.reached ? 1.4 : 1);
505
+ const color = f.reached === 'hidden' ? HIDDEN : TIER_COLORS[row.tier] || '#555';
506
+ return (
507
+ <g key={row.id}>
508
+ {(few || f.reached) && (
509
+ <line x1={0} y1={0} x2={p.x} y2={p.y} stroke={f.reached ? GREEN : color}
510
+ strokeWidth={0.6 / k} strokeOpacity={f.reached ? 0.3 : 0.12} />
511
+ )}
512
+ {f.reached === 'ready' && <Halo x={p.x} y={p.y} r={r * 2.3} still={still} />}
513
+ {f.requested ? (
514
+ <circle cx={p.x} cy={p.y} r={r * 1.2} fill="none" stroke={color} strokeWidth={Math.max(0.6, r * 0.45)}
515
+ strokeDasharray={`${Math.max(0.8, r * 0.55)} ${Math.max(0.6, r * 0.45)}`} />
516
+ ) : (
517
+ <circle cx={p.x} cy={p.y} r={r} fill={color} fillOpacity={f.reached ? 1 : 0.85}
518
+ stroke={f.reached && f.reached !== 'hidden' ? GREEN : 'none'} strokeWidth={f.reached ? Math.max(0.8, r * 0.3) : 0} />
519
+ )}
520
+ {f.reached === 'hidden' && <Lock x={p.x} y={p.y - r * 0.1} s={r / 4.2} />}
521
+ {row.id === scanningId && (
522
+ <circle cx={p.x} cy={p.y} r={r * 2} fill="none" stroke={DEGREE_COLORS[3]} strokeWidth={1 / k} strokeDasharray={`${3 / k} ${2 / k}`} />
523
+ )}
524
+ {(f.reached || (roomy && row.tier === 'S')) && (
525
+ <text x={p.x} y={p.y + r + 10 / k} textAnchor="middle" fontSize={8.5 / k} fontWeight={f.reached ? 700 : 400}
526
+ fill={f.reached === 'hidden' ? '#999' : f.reached ? GREEN : '#aaa'} pointerEvents="none"
527
+ stroke="#0a0a1a" strokeWidth={2.4 / k} paintOrder="stroke">
528
+ {firstName(row)}
529
+ </text>
530
+ )}
531
+ </g>
532
+ );
533
+ });
534
+ }, [members, layout, facts, k, still, scanningId]);
535
+
536
+ // Pointer → the member under it (nearest within reach), or the person in the middle.
537
+ const hitAt = (clientX, clientY) => {
538
+ const svg = svgRef.current;
539
+ if (!svg) return null;
540
+ const rect = svg.getBoundingClientRect();
541
+ const wx = (clientX - rect.left - cx - view.x) / k;
542
+ const wy = (clientY - rect.top - cy - view.y) / k;
543
+ if (Math.hypot(wx, wy) <= 30) return 'center';
544
+ const near = Math.max(layout.spacing * 0.6, 10 / k);
545
+ let best = null;
546
+ let bestD = near;
547
+ layout.points.forEach((p, i) => {
548
+ const d = Math.hypot(p.x - wx, p.y - wy);
549
+ if (d < bestD) { bestD = d; best = i; }
550
+ });
551
+ return best;
552
+ };
553
+
554
+ const onPointerDown = (e) => {
555
+ drag.current = { x: e.clientX, y: e.clientY, vx: view.x, vy: view.y, moved: false };
556
+ e.currentTarget.setPointerCapture?.(e.pointerId);
557
+ };
558
+ const onPointerMove = (e) => {
559
+ const d = drag.current;
560
+ if (d) {
561
+ const dx = e.clientX - d.x;
562
+ const dy = e.clientY - d.y;
563
+ if (!d.moved && Math.hypot(dx, dy) > 5) d.moved = true;
564
+ if (d.moved) {
565
+ setView((v) => ({ ...v, x: d.vx + dx, y: d.vy + dy }));
566
+ return;
567
+ }
568
+ }
569
+ const hit = hitAt(e.clientX, e.clientY);
570
+ setHover((h) => (h === hit ? h : hit));
571
+ };
572
+ const onPointerUp = (e) => {
573
+ const d = drag.current;
574
+ drag.current = null;
575
+ if (!d || d.moved) return;
576
+ const hit = hitAt(e.clientX, e.clientY);
577
+ if (hit === 'center') onSelect?.(person);
578
+ else if (hit != null && goesToScan(hit)) router.push(scanPageFor(members[hit]));
579
+ else if (hit != null) onOpen(members[hit].id);
580
+ };
581
+ const zoomBy = (f, at = { x: 0, y: 0 }) => setView((v) => {
582
+ const nk = Math.max(0.5, Math.min(6, v.k * f));
583
+ return { k: nk, x: at.x - ((at.x - v.x) * nk) / v.k, y: at.y - ((at.y - v.y) * nk) / v.k };
584
+ });
585
+ const onWheel = (e) => {
586
+ const rect = svgRef.current?.getBoundingClientRect();
587
+ if (!rect) return;
588
+ zoomBy(Math.exp(-e.deltaY * 0.0015), { x: e.clientX - rect.left - cx, y: e.clientY - rect.top - cy });
589
+ };
590
+
591
+ const ready = facts.filter((f) => f.reached === 'ready').length;
592
+ const hidden = facts.filter((f) => f.reached === 'hidden').length;
593
+ const added = facts.filter((f) => f.reached).length;
594
+ const asked = facts.filter((f) => f.requested).length;
595
+ const byTier = ['S', 'A', 'B', 'C', 'D'].map((t) => [t, members.filter((m) => m.tier === t).length]).filter(([, n]) => n);
596
+ const scanningThem = scanningId != null && scanningId === person.id;
597
+ const hovered = typeof hover === 'number' ? members[hover] : null;
598
+ const hp = typeof hover === 'number' ? layout.points[hover] : null;
599
+
600
+ return (
601
+ <>
602
+ <svg ref={svgRef} width={dims.w} height={dims.h}
603
+ style={{ display: 'block', touchAction: 'none', cursor: hover != null ? 'pointer' : 'grab' }}
604
+ onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp}
605
+ onPointerLeave={() => { if (!drag.current) setHover(null); }} onWheel={onWheel}
606
+ role="img" aria-label={`${person.name}’s circle: ${members.length} ${members.length === 1 ? 'person' : 'people'}`}>
607
+ <defs>
608
+ <filter id="focusGlow"><feGaussianBlur stdDeviation="4" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
609
+ </defs>
610
+ <g transform={`translate(${cx + view.x} ${cy + view.y}) scale(${k})`}>
611
+ {/* Ring guides, one per ring of dots */}
612
+ {layout.rings.map((ring) => (
613
+ <circle key={ring.radius} r={ring.radius} fill="none" stroke={`${DEGREE_COLORS[2]}10`} strokeWidth={1 / k} />
614
+ ))}
615
+ {dots}
616
+ {/* The one under the pointer, on top */}
617
+ {hp && (
618
+ <>
619
+ <line x1={0} y1={0} x2={hp.x} y2={hp.y} stroke={TIER_COLORS[hovered.tier] || '#888'} strokeWidth={1.6 / k} strokeOpacity={0.8} />
620
+ <circle cx={hp.x} cy={hp.y} r={dotRadius(layout.spacing, hovered.tier) * (facts[hover].reached ? 1.4 : 1) + 2 / k}
621
+ fill="none" stroke="#fff" strokeWidth={1.6 / k} />
622
+ </>
623
+ )}
624
+ {/* The person in the middle, with the halo if their own circle is ready to scan */}
625
+ {reachState(person, reach) === 'ready' && <Halo x={0} y={0} r={40} still={still} />}
626
+ <circle r={28} fill="#0a0a1a" stroke={TIER_COLORS[person.tier] || '#888'} strokeWidth={hover === 'center' ? 4 : 3} filter="url(#focusGlow)" />
627
+ {localPhoto(person.profile_image_url) ? (
628
+ <>
629
+ <clipPath id="focus-clip"><circle r={24} /></clipPath>
630
+ <image href={localPhoto(person.profile_image_url)} x={-24} y={-24} width={48} height={48} clipPath="url(#focus-clip)" />
631
+ </>
632
+ ) : (
633
+ <text y={5} textAnchor="middle" fill={TIER_COLORS[person.tier] || '#888'} fontSize={16} fontWeight={800}>
634
+ {person.name?.charAt(0)}
635
+ </text>
636
+ )}
637
+ <text y={40} textAnchor="middle" fill="#fff" fontSize={11} fontWeight={700} pointerEvents="none">{person.name}</text>
638
+ </g>
639
+ {hovered && (
640
+ <Tip x={cx + view.x + hp.x * k} y={cy + view.y + hp.y * k} w={dims.w}
641
+ lines={tipFor(hovered, facts[hover], scanningId, depth, goesToScan(hover))} accent={facts[hover].reached ? GREEN : TIER_COLORS[hovered.tier]} />
642
+ )}
643
+ {hover === 'center' && (
644
+ <Tip x={cx + view.x} y={cy + view.y - 28 * k} w={dims.w} lines={[person.name, 'Open their card']} accent={TIER_COLORS[person.tier]} />
645
+ )}
646
+ </svg>
647
+
648
+ {/* The way back: one circle at a time, or straight to any on the trail.
649
+ Clear of the Filters tab at the left edge. */}
650
+ <div style={{ position: 'absolute', top: 14, left: 64, right: 64, pointerEvents: 'none', display: 'flex', flexDirection: 'column', gap: 8, alignItems: 'flex-start' }}>
651
+ <div style={{ display: 'flex', alignItems: 'center', gap: 10, flexWrap: 'wrap', pointerEvents: 'auto' }}>
652
+ <button type="button" onClick={() => onBack(depth - 1)} style={{
653
+ padding: '8px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
654
+ background: 'rgba(255,255,255,0.1)', color: '#ddd', fontSize: 12, fontWeight: 700,
655
+ }}>
656
+ ← {depth === 1 ? 'All bridges' : `${firstName(trail[depth - 2])}’s circle`}
657
+ </button>
658
+ <nav aria-label="Trail" style={{ fontSize: 11, color: '#777', display: 'flex', gap: 6, flexWrap: 'wrap', alignItems: 'center' }}>
659
+ <button type="button" onClick={() => onBack(0)} style={crumb}>All bridges</button>
660
+ {trail.map((row, i) => (
661
+ <span key={row.id} style={{ display: 'inline-flex', gap: 6, alignItems: 'center' }}>
662
+ <span aria-hidden="true">›</span>
663
+ {i === depth - 1
664
+ ? <span style={{ color: '#fff', fontWeight: 700 }}>{row.name}</span>
665
+ : <button type="button" onClick={() => onBack(i + 1)} style={crumb}>{row.name}</button>}
666
+ </span>
667
+ ))}
668
+ </nav>
669
+ </div>
670
+ <div style={{ fontSize: 11, color: '#999' }}>
671
+ {firstName(person)}’s circle · {members.length.toLocaleString('en-US')} {members.length === 1 ? 'person' : 'people'}
672
+ {' · '}{ordinal(depth + 1)} degree{depth > 1 ? ', counted along this chain' : ''}
673
+ </div>
674
+ {scanningThem && members.length > 0 && (
675
+ <div role="status" style={{ ...note, borderColor: 'rgba(52,152,219,0.4)', color: '#cfe6f7' }}>
676
+ Scanning {firstName(person)}’s circle now. More people appear here as it saves, every 10 pages.
677
+ </div>
678
+ )}
679
+ </div>
680
+
681
+ {members.length === 0 && (
682
+ <EmptyCircle person={person} depth={depth} reach={reach} requests={requests}
683
+ scanning={scanningThem} canScan={canScan} onSelect={onSelect} top={cy + 64} />
684
+ )}
685
+
686
+ {/* What's in this circle */}
687
+ {members.length > 0 && (
688
+ <div style={{
689
+ position: 'absolute', bottom: 20, left: 20, maxWidth: 'calc(100% - 110px)',
690
+ background: 'rgba(0,0,0,0.75)', borderRadius: 10, padding: '10px 14px',
691
+ backdropFilter: 'blur(8px)', fontSize: 10, color: '#888', lineHeight: 1.6,
692
+ }}>
693
+ <div style={{ fontSize: 9, fontWeight: 700, color: '#666', letterSpacing: 1, marginBottom: 6 }}>IN THIS CIRCLE</div>
694
+ {byTier.map(([t, n]) => (
695
+ <div key={t} style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
696
+ <div style={{ width: 6, height: 6, borderRadius: '50%', background: TIER_COLORS[t] }} />
697
+ <span style={{ color: TIER_COLORS[t], fontWeight: 600, width: 40 }}>{t}-Tier</span>
698
+ <span>{n.toLocaleString('en-US')}</span>
699
+ </div>
700
+ ))}
701
+ {(added > 0 || asked > 0) && (
702
+ <div style={{ marginTop: 6 }}>
703
+ {added > 0 && <div><span style={{ color: GREEN }}>● </span>you added {added}{ready ? `, ${ready} ready to scan` : ''}{hidden ? `, ${hidden} hidden` : ''}</div>}
704
+ {asked > 0 && <div><span style={{ color: '#FFD700' }}>◌ </span>{asked} {asked === 1 ? 'request' : 'requests'} out</div>}
705
+ </div>
706
+ )}
707
+ <div style={{ marginTop: 6, color: '#555' }}>
708
+ Click anyone for their circle{ready > 0 && canScan ? ', or someone ready to scan theirs' : ''} · drag to move · scroll to zoom
709
+ </div>
710
+ </div>
711
+ )}
712
+
713
+ <ZoomButtons onIn={() => zoomBy(1.35)} onReset={() => setView({ k: 1, x: 0, y: 0 })} onOut={() => zoomBy(1 / 1.35)} />
714
+ </>
715
+ );
716
+ }
717
+
718
+ /** The lines of a dot's tooltip. `toScan`: a click goes to the Scan page with them picked. */
719
+ function tipFor(row, f, scanningId, depth, toScan) {
720
+ const first = firstName(row);
721
+ const status = row.id === scanningId ? 'Their circle is being scanned now'
722
+ : f.reached === 'ready' ? 'You added them · their circle is ready to scan'
723
+ : f.reached === 'hidden' ? 'You added them · their list is hidden'
724
+ : f.reached === 'scanned' ? `You added them · ${f.own.toLocaleString('en-US')} in their circle`
725
+ : f.requested ? 'Request sent'
726
+ : 'Not connected yet';
727
+ const next = toScan ? `Click to scan ${first}’s circle on the Scan page`
728
+ : f.own > 0 ? `Click to open ${first}’s circle (${ordinal(depth + 2)} degree)` : `Click to open ${first}’s circle`;
729
+ return [row.name, `${row.tier}-tier · ${score(row).toFixed(1)}`, status, next];
730
+ }
731
+
732
+ /** A tooltip in screen space, above the point, kept inside the window. */
733
+ function Tip({ x, y, w, lines, accent }) {
734
+ const width = Math.max(150, Math.min(240, Math.max(...lines.map((l) => String(l).length)) * 5.6 + 20));
735
+ const height = 12 + lines.length * 12;
736
+ const left = Math.max(6, Math.min(w - width - 6, x - width / 2));
737
+ const top = Math.max(6, y - height - 12);
738
+ return (
739
+ <g pointerEvents="none">
740
+ <rect x={left} y={top} width={width} height={height} rx={6} fill="rgba(0,0,0,0.92)" stroke={accent || '#555'} strokeWidth={0.6} />
741
+ {lines.map((line, i) => (
742
+ <text key={i} x={left + width / 2} y={top + 16 + i * 12} textAnchor="middle"
743
+ fill={i === 0 ? '#fff' : i === 2 ? accent || '#aaa' : '#999'} fontSize={i === 0 ? 10.5 : 9} fontWeight={i === 0 ? 700 : 400}>
744
+ {line}
745
+ </text>
746
+ ))}
747
+ </g>
748
+ );
749
+ }
750
+
751
+ /**
752
+ * An empty circle, and why: nobody's in it until you're connected and it's
753
+ * been scanned, so say which of those is missing and what gets it.
754
+ */
755
+ function EmptyCircle({ person, depth, reach, requests, scanning, canScan, onSelect, top }) {
756
+ const first = firstName(person);
757
+ const next = ordinal(depth + 1);
758
+ const yours = person.degree === 1;
759
+ const state = yours ? circleState(person, reach) : null;
760
+ const cost = circleScanCost();
761
+ let title;
762
+ let body;
763
+ let action = null;
764
+ if (scanning) {
765
+ title = `Scanning ${first}’s circle now`;
766
+ body = 'Their people appear here as the scan saves them, every 10 pages.';
767
+ } else if (state === 'hidden') {
768
+ title = `${first} keeps their connections hidden`;
769
+ body = 'LinkedIn doesn’t show their list, so there is no circle to scan.';
770
+ } else if (state === 'scanned') {
771
+ title = `${first}’s list has been read`;
772
+ body = 'Everyone on it was already one of your connections, so nobody new is here.';
773
+ } else if (yours) {
774
+ title = `${first}’s circle isn’t scanned yet`;
775
+ body = `${first} is your connection, so their circle can be scanned. That brings in the people they know: ${next} degree${depth > 1 ? ', counted along this chain' : ''}.`;
776
+ if (canScan) {
777
+ action = (
778
+ <>
779
+ <Link href={scanPageFor(person)} style={primary}>Scan {first}’s circle →</Link>
780
+ <div style={{ fontSize: 10.5, color: '#888', marginTop: 8 }}>
781
+ {cost.profileViews} profile view, then one LinkedIn search per page of their list (up to {cost.searches}, about {cost.minutes} min).
782
+ It starts only once you confirm it on the Scan page.
783
+ </div>
784
+ </>
785
+ );
786
+ }
787
+ } else if (hasRequest(person, requests)) {
788
+ title = `Request sent to ${first}`;
789
+ body = `Once ${first} accepts, ↻ Check for new brings them into your connections. Then their circle can be scanned, and the people they know fill in here: ${next} degree.`;
790
+ } else {
791
+ title = `Nobody from ${first}’s circle yet`;
792
+ body = `LinkedIn only shows the connections of people you’re connected to. Connect with ${first}; once they accept and their circle is scanned, the people they know fill in here: ${next} degree.`;
793
+ action = <button type="button" onClick={() => onSelect?.(person)} style={secondary}>Open {first}’s card</button>;
794
+ }
795
+ return (
796
+ <div role="status" style={{
797
+ position: 'absolute', left: '50%', top, transform: 'translateX(-50%)', width: 'min(360px, calc(100% - 32px))',
798
+ background: 'rgba(10,10,26,0.94)', border: `1px solid ${state === 'hidden' ? 'rgba(255,255,255,0.12)' : 'rgba(0,255,136,0.25)'}`,
799
+ borderRadius: 12, padding: '14px 16px', textAlign: 'center',
800
+ }}>
801
+ <div style={{ fontSize: 13, fontWeight: 800, color: state === 'hidden' ? '#aaa' : '#fff', marginBottom: 6 }}>
802
+ {state === 'hidden' && <span aria-hidden="true">🔒 </span>}{title}
803
+ </div>
804
+ <div style={{ fontSize: 11.5, color: '#9aa', lineHeight: 1.55, marginBottom: action ? 12 : 0 }}>{body}</div>
805
+ {action}
806
+ </div>
807
+ );
808
+ }
809
+
810
+ const crumb = { background: 'none', border: 'none', color: '#8fb8d6', fontSize: 11, fontWeight: 600, cursor: 'pointer', padding: 0 };
811
+ const note = {
812
+ fontSize: 11, padding: '6px 10px', borderRadius: 8, border: '1px solid', background: 'rgba(0,0,0,0.6)', pointerEvents: 'auto',
813
+ };
814
+ const primary = {
815
+ display: 'inline-block', padding: '9px 16px', borderRadius: 8, textDecoration: 'none', fontSize: 12.5, fontWeight: 800,
816
+ color: '#0a0a1a', background: 'linear-gradient(135deg, #00ff88, #3498DB)',
817
+ };
818
+ const secondary = {
819
+ padding: '8px 14px', borderRadius: 8, cursor: 'pointer', fontSize: 12, fontWeight: 700,
820
+ border: '1px solid rgba(255,255,255,0.18)', background: 'rgba(255,255,255,0.06)', color: '#ddd',
821
+ };