six-degrees 0.4.0 → 0.4.2

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 (209) hide show
  1. package/.next/standalone/.next/BUILD_ID +1 -1
  2. package/.next/standalone/.next/build-manifest.json +3 -3
  3. package/.next/standalone/.next/prerender-manifest.json +14 -14
  4. package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
  7. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +2 -2
  9. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  10. package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
  11. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
  13. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
  14. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  15. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  16. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  17. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/ingest/route.js +1 -1
  30. package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
  31. package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
  32. package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
  33. package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
  34. package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
  35. package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
  36. package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
  37. package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
  38. package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
  39. package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
  40. package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
  41. package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
  42. package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
  43. package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
  44. package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
  45. package/.next/standalone/.next/server/app/favicon.ico/route.js.nft.json +1 -1
  46. package/.next/standalone/.next/server/app/favicon.ico.body +0 -0
  47. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  48. package/.next/standalone/.next/server/app/import.html +1 -1
  49. package/.next/standalone/.next/server/app/import.rsc +2 -2
  50. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +2 -2
  51. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +1 -1
  52. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +1 -1
  53. package/.next/standalone/.next/server/app/index.html +1 -1
  54. package/.next/standalone/.next/server/app/index.rsc +3 -3
  55. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
  56. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
  57. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  58. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  59. package/.next/standalone/.next/server/app/launch.html +2 -2
  60. package/.next/standalone/.next/server/app/launch.rsc +2 -2
  61. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +2 -2
  62. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +1 -1
  63. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +1 -1
  64. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  65. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  66. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  67. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  68. package/.next/standalone/.next/server/app/paths.html +1 -1
  69. package/.next/standalone/.next/server/app/paths.rsc +3 -3
  70. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +3 -3
  71. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +1 -1
  72. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +2 -2
  73. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  74. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  75. package/.next/standalone/.next/server/app/profile.html +1 -1
  76. package/.next/standalone/.next/server/app/profile.rsc +3 -3
  77. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +3 -3
  78. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
  79. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +2 -2
  80. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  81. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  82. package/.next/standalone/.next/server/app/queue.html +1 -1
  83. package/.next/standalone/.next/server/app/queue.rsc +3 -3
  84. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +3 -3
  85. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
  86. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +2 -2
  87. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  88. package/.next/standalone/.next/server/app/scores.html +1 -1
  89. package/.next/standalone/.next/server/app/scores.rsc +2 -2
  90. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +2 -2
  91. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
  92. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +1 -1
  93. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  94. package/.next/standalone/.next/server/app/settings.html +1 -1
  95. package/.next/standalone/.next/server/app/settings.rsc +2 -2
  96. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  97. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  98. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +1 -1
  99. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  100. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  101. package/.next/standalone/.next/server/app/setup.html +1 -1
  102. package/.next/standalone/.next/server/app/setup.rsc +3 -3
  103. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +3 -3
  104. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
  105. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +2 -2
  106. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e0fei3._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__03ydxxh._.js → [root-of-the-server]__20tmem7._.js} +1 -1
  108. package/.next/standalone/.next/server/chunks/_1_fynia._.js +6 -6
  109. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  110. package/.next/standalone/.next/server/chunks/_1iwkh8t._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/_1mxn_lb._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +21 -7
  113. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00d8snt._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00g61b8._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08lf3gy._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hg5gib._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ho-u0w._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ze473p._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1194jps._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1cnxxc9._.js +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ll97ry._.js +1 -1
  122. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m0255u._.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1w9z0zv._.js +1 -1
  124. package/.next/standalone/.next/server/chunks/ssr/_15xuwkv._.js +1 -1
  125. package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +1 -1
  126. package/.next/standalone/.next/server/chunks/ssr/_20w06i-._.js +1 -1
  127. package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +4 -4
  128. package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +1 -1
  129. package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +6 -2
  130. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  131. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  132. package/.next/standalone/.next/server/pages/404.html +1 -1
  133. package/.next/standalone/.next/server/pages/500.html +1 -1
  134. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  135. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  136. package/.next/standalone/.next/static/chunks/{3e8zg6ctvvb8p.js → 049vavjsmqgmn.js} +3 -3
  137. package/.next/standalone/.next/static/chunks/0ljxa-wk_iu__.js +18 -0
  138. package/.next/standalone/.next/static/chunks/0mp9b72fm-jp6.js +6 -0
  139. package/.next/standalone/.next/static/chunks/1pre1cotntk6-.js +1 -0
  140. package/.next/standalone/.next/static/chunks/{18xu0fbgo_qz-.js → 22x2_r768-u5i.js} +1 -1
  141. package/.next/standalone/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
  142. package/.next/standalone/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
  143. package/.next/standalone/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
  144. package/.next/standalone/CHANGELOG.md +86 -0
  145. package/.next/standalone/app/api/admin-delete/route.js +3 -0
  146. package/.next/standalone/app/api/ingest/route.js +13 -0
  147. package/.next/standalone/app/api/scraper/route.js +10 -2
  148. package/.next/standalone/app/components/ChainView.js +76 -15
  149. package/.next/standalone/app/components/FilterPanel.js +37 -1
  150. package/.next/standalone/app/components/ForceGraph.js +144 -12
  151. package/.next/standalone/app/components/LinkedInLimits.js +20 -3
  152. package/.next/standalone/app/components/OrbitGraph.js +92 -30
  153. package/.next/standalone/app/components/Sidebar.js +37 -0
  154. package/.next/standalone/app/components/views.js +7 -5
  155. package/.next/standalone/app/favicon.ico +0 -0
  156. package/.next/standalone/app/page.js +24 -7
  157. package/.next/standalone/app/paths/page.js +8 -1
  158. package/.next/standalone/db/schema.js +14 -0
  159. package/.next/standalone/lib/brokerage.js +94 -0
  160. package/.next/standalone/lib/chain-layout.js +89 -0
  161. package/.next/standalone/lib/circle.js +23 -4
  162. package/.next/standalone/lib/degrees.js +30 -0
  163. package/.next/standalone/lib/dot-rings.js +21 -0
  164. package/.next/standalone/lib/ingest.js +3 -3
  165. package/.next/standalone/lib/linkedin-limits.js +9 -4
  166. package/.next/standalone/lib/reach.js +22 -1
  167. package/.next/standalone/lib/search-risk.js +38 -0
  168. package/.next/standalone/lib/ties.js +23 -0
  169. package/.next/standalone/package-lock.json +2 -2
  170. package/.next/standalone/package.json +1 -1
  171. package/.next/standalone/scripts/scrape.py +50 -12
  172. package/.next/static/chunks/{3e8zg6ctvvb8p.js → 049vavjsmqgmn.js} +3 -3
  173. package/.next/static/chunks/0ljxa-wk_iu__.js +18 -0
  174. package/.next/static/chunks/0mp9b72fm-jp6.js +6 -0
  175. package/.next/static/chunks/1pre1cotntk6-.js +1 -0
  176. package/.next/static/chunks/{18xu0fbgo_qz-.js → 22x2_r768-u5i.js} +1 -1
  177. package/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
  178. package/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
  179. package/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
  180. package/CHANGELOG.md +86 -0
  181. package/db/schema.js +14 -0
  182. package/lib/brokerage.js +94 -0
  183. package/lib/chain-layout.js +89 -0
  184. package/lib/circle.js +23 -4
  185. package/lib/degrees.js +30 -0
  186. package/lib/dot-rings.js +21 -0
  187. package/lib/ingest.js +3 -3
  188. package/lib/linkedin-limits.js +9 -4
  189. package/lib/reach.js +22 -1
  190. package/lib/search-risk.js +38 -0
  191. package/lib/ties.js +23 -0
  192. package/package.json +1 -1
  193. package/scripts/scrape.py +50 -12
  194. package/.next/standalone/.next/static/chunks/0w76iraw7ij3k.js +0 -18
  195. package/.next/standalone/.next/static/chunks/135n87ydxjrra.js +0 -2
  196. package/.next/standalone/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
  197. package/.next/standalone/.next/static/chunks/3qh78g2aqhprd.js +0 -2
  198. package/.next/standalone/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
  199. package/.next/static/chunks/0w76iraw7ij3k.js +0 -18
  200. package/.next/static/chunks/135n87ydxjrra.js +0 -2
  201. package/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
  202. package/.next/static/chunks/3qh78g2aqhprd.js +0 -2
  203. package/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
  204. /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_buildManifest.js +0 -0
  205. /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_clientMiddlewareManifest.js +0 -0
  206. /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_ssgManifest.js +0 -0
  207. /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_buildManifest.js +0 -0
  208. /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_clientMiddlewareManifest.js +0 -0
  209. /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_ssgManifest.js +0 -0
@@ -1,9 +1,11 @@
1
1
  'use client';
2
2
 
3
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useMemo, useRef, useState } from 'react';
4
4
  import * as d3 from 'd3';
5
5
  import { localPhoto } from '../../lib/photos';
6
6
  import { recentre } from '../../lib/galaxy';
7
+ import { reachIndex, readyByCircle, scanBars } from '../../lib/reach';
8
+ import { ringSegments, RING } from '../../lib/dot-rings';
7
9
 
8
10
  // Connection fields are attacker-reachable: /api/ingest and /api/update-images
9
11
  // accept writes, and a page on any other site can POST to this app on localhost.
@@ -57,7 +59,21 @@ const RING_CSS = `
57
59
  .galaxy-ring-pulse { animation: none; opacity: 0.8; }
58
60
  }`;
59
61
 
60
- export default function ForceGraph({ connections, onSelect, tierColors, focusNodeRef, userName, selectedId = null }) {
62
+ export default function ForceGraph({ connections, onSelect, tierColors, focusNodeRef, userName, selectedId = null, scanNotes, fullDegree1, fullDegree2 }) {
63
+ // The ring round a connection's dot (design C): how much of their circle is
64
+ // scanned, and how many in it are ready to scan (lib/reach.js).
65
+ const marks = useMemo(() => {
66
+ const reach = reachIndex(fullDegree1 || connections, fullDegree2 || [], scanNotes || {});
67
+ const ready = readyByCircle(reach);
68
+ const out = new Map();
69
+ for (const c of connections) {
70
+ if ((c.degree || 1) !== 1) continue;
71
+ const bars = scanBars(c, reach);
72
+ const r = ready.get(c.id) || 0;
73
+ if (bars != null || r) out.set(c.id, { bars, ready: r });
74
+ }
75
+ return out;
76
+ }, [connections, fullDegree1, fullDegree2, scanNotes]);
61
77
  const svgRef = useRef(null);
62
78
  const ringRef = useRef(null);
63
79
  const sceneRef = useRef(null); // The scene on screen: { resize, select }
@@ -123,7 +139,7 @@ export default function ForceGraph({ connections, onSelect, tierColors, focusNod
123
139
  const saved = transform ? recentre(transform, setFor, size) : null;
124
140
 
125
141
  const select = (d) => onSelectRef.current?.(d);
126
- const scene = renderNetworkMode(svg, ringRef.current, size, connections, select, tierColors, focusNodeRef, saved, viewRef, userName);
142
+ const scene = renderNetworkMode(svg, ringRef.current, size, connections, select, tierColors, focusNodeRef, saved, viewRef, userName, marks);
127
143
  sceneRef.current = scene;
128
144
  scene.select(selectedIdRef.current);
129
145
 
@@ -145,7 +161,7 @@ export default function ForceGraph({ connections, onSelect, tierColors, focusNod
145
161
  // why onSelect is not a dependency. The size is read through sizeRef: a new
146
162
  // size re-fits the view rather than rebuilding.
147
163
  // eslint-disable-next-line react-hooks/exhaustive-deps
148
- }, [connections, tierColors, measured]);
164
+ }, [connections, tierColors, measured, marks]);
149
165
 
150
166
  return (
151
167
  <div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
@@ -200,14 +216,15 @@ function easeRadius(el, r) {
200
216
  // Graph coordinates put you at 0,0, whatever the size of the box; the zoom
201
217
  // transform places that in the box. So a new size only moves the view, and a
202
218
  // rebuild can start from the view as it was.
203
- function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, focusNodeRef, savedTransform, viewRef, userName) {
219
+ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, focusNodeRef, savedTransform, viewRef, userName, marks = new Map()) {
204
220
  const centerNode = {
205
221
  id: CENTER_ID, name: userName || 'You', tier: 'center', degree: 0,
206
222
  power_score: 10, fx: 0, fy: 0,
207
223
  };
208
224
 
209
225
  const nodes = [centerNode, ...connections.map(c => ({
210
- id: c.id, name: c.name, tier: c.tier, degree: 1,
226
+ id: c.id, name: c.name, tier: c.tier, degree: c.degree || 1, source_connection_id: c.source_connection_id,
227
+ unlocked_from_bridge_id: c.unlocked_from_bridge_id,
211
228
  power_score: parseFloat(c.power_score) || 1,
212
229
  company: c.company, role: c.role, headline: c.headline,
213
230
  profile_url: c.profile_url, profile_image_url: localPhoto(c.profile_image_url),
@@ -219,15 +236,59 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
219
236
  }))];
220
237
  const nodeById = new Map(nodes.map(n => [n.id, n]));
221
238
 
222
- const links = connections.map(c => ({ source: CENTER_ID, target: c.id }));
239
+ // Who hangs off whom. Your connections hang off you; someone in a circle off
240
+ // the connection whose circle it is, when that one is drawn too; and with
241
+ // more than your connections drawn (Filter → Degree), someone you added
242
+ // through a circle off the one they came from, so a chain reads as a chain.
243
+ const drawnD1 = new Set(connections.filter(c => (c.degree || 1) === 1).map(c => c.id));
244
+ const multi = connections.some(c => (c.degree || 1) > 1);
245
+ const parentOf = new Map();
246
+ for (const n of nodes) {
247
+ if (n.id === CENTER_ID) continue;
248
+ const from = n.unlocked_from_bridge_id;
249
+ if (n.degree === 1 && multi && from != null && from !== n.id && drawnD1.has(from)) parentOf.set(n.id, from);
250
+ else if (n.degree === 2 && drawnD1.has(n.source_connection_id)) parentOf.set(n.id, n.source_connection_id);
251
+ }
252
+ // How far along the chain: 1 for your connections, one more past each person
253
+ // you came through, to 6.
254
+ const bandMemo = new Map();
255
+ const bandOf = (n, seen = new Set()) => {
256
+ if (n.id === CENTER_ID) return 0;
257
+ if (bandMemo.has(n.id)) return bandMemo.get(n.id);
258
+ const p = parentOf.get(n.id);
259
+ let b = n.degree >= 3 ? 3 : n.degree;
260
+ if (p != null && !seen.has(n.id)) { seen.add(n.id); b = bandOf(nodeById.get(p), seen) + 1; }
261
+ b = Math.min(b, 6);
262
+ bandMemo.set(n.id, b);
263
+ return b;
264
+ };
265
+ const links = connections.map(c => {
266
+ const via = parentOf.get(c.id) ?? CENTER_ID;
267
+ return { source: via, target: c.id, near: via !== CENTER_ID };
268
+ });
223
269
 
224
270
  const tierRadius = (tier) => {
225
271
  switch (tier) { case 'S': return 150; case 'A': return 250; case 'B': return 350; case 'C': return 450; default: return 520; }
226
272
  };
273
+ // More than one band drawn: a band for each step along the chain, the first
274
+ // (your connections) a tighter ring, each sorted by tier with S nearest, close
275
+ // enough to read as layers and far enough apart not to clump. One band alone
276
+ // keeps the roomier tier rings.
277
+ const TIERS = ['S', 'A', 'B', 'C', 'D'];
278
+ const SLOTS = [[115, 135, 155, 175, 192], [265, 300, 335, 370, 398], [465, 500, 535, 565, 590], [660, 690, 720, 745, 765]];
279
+ const bands = [...new Set(nodes.filter(n => n.id !== CENTER_ID).map(n => bandOf(n)))].sort((a, b) => a - b);
280
+ const layered = multi && bands.length > 1;
281
+ const radiusOf = (d) => {
282
+ if (!layered) return tierRadius(d.tier);
283
+ const slot = Math.min(bands.indexOf(bandOf(d)), SLOTS.length - 1);
284
+ const t = TIERS.indexOf(d.tier);
285
+ return SLOTS[slot][t < 0 ? 4 : t];
286
+ };
227
287
 
228
288
  const nodeRadius = (d) => {
229
289
  if (d.id === CENTER_ID) return 18;
230
- return Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
290
+ const r = Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
291
+ return d.degree > 1 ? Math.max(2.5, r * 0.75) : r; // further out, a little smaller
231
292
  };
232
293
 
233
294
  // A phone. Judged by the window, as the page judges it, not by the graph's
@@ -235,6 +296,37 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
235
296
  const isMobileGraph = window.innerWidth < 768;
236
297
 
237
298
  const g = svg.append('g');
299
+
300
+ // Faint S–D guide rings where each tier settles (the radial force below), so
301
+ // the tier bands read at a glance. Not on a phone, whose layout has no rings.
302
+ // Once the layout settles they move to where each tier's dots actually sit
303
+ // (the median distance out), which the other forces push past that radius.
304
+ const guideRings = new Map();
305
+ if (!isMobileGraph && layered) {
306
+ // In bands: a faint ring and a label for each step along the chain instead.
307
+ const guides = g.append('g').attr('class', 'degree-guides').style('pointer-events', 'none');
308
+ const names = ['', '1st', '2nd', '3rd', '4th', '5th', '6th'];
309
+ bands.slice(0, SLOTS.length).forEach((b, slot) => {
310
+ const r = SLOTS[slot][2];
311
+ guides.append('circle').attr('r', r).attr('fill', 'none').attr('stroke', '#8899aa')
312
+ .attr('stroke-opacity', 0.12).attr('stroke-width', 1).attr('stroke-dasharray', '2 4');
313
+ guides.append('text').attr('x', 0).attr('y', -SLOTS[slot][4] - 8).attr('text-anchor', 'middle')
314
+ .attr('font-size', 10).attr('font-weight', 700).attr('fill', '#8899aa').attr('fill-opacity', 0.55)
315
+ .text(names[b] || `${b}th`);
316
+ });
317
+ } else if (!isMobileGraph) {
318
+ const guides = g.append('g').attr('class', 'tier-guides').style('pointer-events', 'none');
319
+ for (const tier of ['S', 'A', 'B', 'C', 'D']) {
320
+ const r = tierRadius(tier);
321
+ const ring = guides.append('circle').attr('r', r).attr('fill', 'none')
322
+ .attr('stroke', tierColors[tier] || '#666').attr('stroke-opacity', 0.16).attr('stroke-width', 1)
323
+ .attr('stroke-dasharray', '2 4');
324
+ const label = guides.append('text').attr('x', 0).attr('y', -r - 4).attr('text-anchor', 'middle')
325
+ .attr('font-size', 10).attr('font-weight', 700).attr('fill', tierColors[tier] || '#666').attr('fill-opacity', 0.5)
326
+ .text(tier);
327
+ guideRings.set(tier, { ring, label });
328
+ }
329
+ }
238
330
  // Where the view is going while a transition takes it there. A resize part-way
239
331
  // through re-fits the destination, and a rebuild starts from it, rather than
240
332
  // from a point along the way.
@@ -330,11 +422,20 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
330
422
  }
331
423
 
332
424
  const simulation = d3.forceSimulation(nodes)
333
- .force('link', d3.forceLink(links).id(d => d.id).distance(d => tierRadius(d.target.tier || 'D')).strength(isMobileGraph ? 0 : 0.1))
425
+ .force('link', d3.forceLink(links).id(d => d.id).distance(d => (d.near ? Math.max(40, radiusOf(d.target) - radiusOf(d.source)) : radiusOf(d.target))).strength(isMobileGraph ? 0 : layered ? 0.06 : 0.1))
334
426
  .force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
335
427
  .force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
336
428
  .force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
337
- .force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier), 0, 0).strength(0.3));
429
+ .force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : radiusOf(d), 0, 0).strength(layered ? 0.45 : 0.3));
430
+ simulation.on('end.guides', () => {
431
+ for (const [tier, { ring, label }] of guideRings) {
432
+ const out = nodes.filter((n) => n.tier === tier && n.id !== CENTER_ID).map((n) => Math.hypot(n.x || 0, n.y || 0)).sort((x, y) => x - y);
433
+ if (!out.length) { ring.attr('opacity', 0); label.attr('opacity', 0); continue; }
434
+ const r = out[Math.floor(out.length / 2)];
435
+ ring.attr('r', r).attr('opacity', 1);
436
+ label.attr('y', -r - 4).attr('opacity', 1);
437
+ }
438
+ });
338
439
 
339
440
  // forceLink has already swapped each link's ids for the nodes themselves, so
340
441
  // the colour is one step away. It used to search every node for every link:
@@ -344,7 +445,10 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
344
445
  .attr('stroke-opacity', 0.15).attr('stroke-width', 0.5);
345
446
 
346
447
  // Catalyst outer glow rings (rendered behind the nodes)
347
- const catalysts = nodes.filter(n => n.is_catalyst);
448
+ // A catalyst is the green outline on their dot (design C, with the scan
449
+ // bars round it); the dashed glow ring that used to sit here hid the bars,
450
+ // so it's only drawn for a catalyst with no bars.
451
+ const catalysts = nodes.filter(n => n.is_catalyst && marks.get(n.id)?.bars == null);
348
452
  if (catalysts.length) {
349
453
  const defs = svg.select('defs').size() ? svg.select('defs') : svg.append('defs');
350
454
  if (!defs.select('#catalyst-glow-nm').size()) {
@@ -361,9 +465,34 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
361
465
  .attr('class', 'catalyst-ring');
362
466
  }
363
467
 
468
+ // Design C round a connection's dot: five thin bars for how much of their
469
+ // circle is scanned, and a small badge for how many in it are ready to scan.
470
+ const ringed = nodes.filter((n) => marks.has(n.id));
471
+ const dotRings = g.append('g').attr('class', 'dot-rings').style('pointer-events', 'none')
472
+ .selectAll('g').data(ringed).join('g')
473
+ .attr('transform', d => `translate(${d.x || 0},${d.y || 0})`);
474
+ dotRings.each(function (d) {
475
+ const { bars, ready } = marks.get(d.id);
476
+ const r = nodeRadius(d) + 3.5;
477
+ const el = d3.select(this);
478
+ if (bars != null) {
479
+ for (const seg of ringSegments(r)) {
480
+ el.append('path').attr('d', seg.d).attr('fill', 'none').attr('stroke-linecap', 'round')
481
+ .attr('stroke-width', 1.6).attr('stroke', seg.i < bars ? RING.filled : RING.empty);
482
+ }
483
+ }
484
+ if (ready > 0) {
485
+ const at = r * 0.72;
486
+ el.append('circle').attr('cx', at).attr('cy', at).attr('r', 5.5).attr('fill', RING.badge);
487
+ el.append('text').attr('x', at).attr('y', at + 2.5).attr('text-anchor', 'middle')
488
+ .attr('font-size', 7).attr('font-weight', 700).attr('fill', '#fff').text(ready > 9 ? '9+' : ready);
489
+ }
490
+ });
491
+
364
492
  const node = g.append('g').selectAll('circle').data(nodes).join('circle')
365
493
  .attr('r', nodeRadius)
366
494
  .attr('fill', d => d.id === CENTER_ID ? '#fff' : tierColors[d.tier] || '#666')
495
+ .attr('fill-opacity', d => (d.degree >= 3 ? 0.55 : d.degree === 2 ? 0.75 : 1))
367
496
  .attr('stroke', d => {
368
497
  if (d.id === CENTER_ID) return '#FFD700';
369
498
  if (d.is_catalyst) return '#00ff88';
@@ -425,7 +554,9 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
425
554
  setupDrag(node, simulation, CENTER_ID);
426
555
 
427
556
  const labels = g.append('g').selectAll('text')
428
- .data(nodes.filter(n => n.id === CENTER_ID || n.tier === 'S' || n.is_catalyst))
557
+ // Names for you, your S-tier connections and catalysts; further out (the
558
+ // Degree filter) only on hover, or hundreds of S-tier names pile up.
559
+ .data(nodes.filter(n => n.id === CENTER_ID || (n.degree === 1 && (n.tier === 'S' || n.is_catalyst))))
429
560
  .join('text')
430
561
  .text(d => d.is_catalyst && d.tier !== 'S' ? d.name + ' ⚡' : d.name)
431
562
  .attr('font-size', d => d.id === CENTER_ID ? 14 : 10)
@@ -444,6 +575,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
444
575
  link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
445
576
  node.attr('cx', d => d.x).attr('cy', d => d.y);
446
577
  catalystRings.attr('cx', d => d.x).attr('cy', d => d.y);
578
+ dotRings.attr('transform', d => `translate(${d.x},${d.y})`);
447
579
  labels.attr('x', d => d.x).attr('y', d => d.y);
448
580
  placeRing();
449
581
  });
@@ -7,6 +7,7 @@
7
7
  // files the scanner writes.
8
8
 
9
9
  import { useState } from 'react';
10
+ import { limitQuestion, limitNote, SAFE_LIMITS } from '../../lib/search-risk';
10
11
 
11
12
  const LINE = '1px solid rgba(255,255,255,0.1)';
12
13
  const TIER = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
@@ -59,6 +60,13 @@ export function BudgetBox({ li, onSetLimits, disabled }) {
59
60
  const today = li.unreadable ? '?' : li.searchesToday;
60
61
  const views = li.unreadable ? '?' : li.profilesToday;
61
62
  const set = (change) => onSetLimits({ ...limits, ...change });
63
+ // A budget past what LinkedIn has put up with is asked about first (lib/search-risk.js).
64
+ const ask = (change) => {
65
+ const q = limitQuestion(limits, { ...limits, ...change });
66
+ if (q && !window.confirm(q)) return;
67
+ set(change);
68
+ };
69
+ const note = limitNote(limits);
62
70
  return (
63
71
  <div style={{ padding: '12px 14px', borderRadius: 8, border: LINE, background: 'rgba(255,255,255,0.03)', fontSize: 12.5, color: '#b8c4c4' }}>
64
72
  <div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 14 }}>
@@ -77,10 +85,10 @@ export function BudgetBox({ li, onSetLimits, disabled }) {
77
85
  </div>
78
86
  <div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 10 }}>
79
87
  <span>Budget:</span>
80
- <select value={limits.daily} disabled={disabled} onChange={(e) => set({ daily: Number(e.target.value) })} style={sel}>
88
+ <select value={limits.daily} disabled={disabled} onChange={(e) => ask({ daily: Number(e.target.value) })} style={sel}>
81
89
  {DAILY.map((n) => <option key={n} value={n}>{n} a day</option>)}
82
90
  </select>
83
- <select value={limits.monthly} disabled={disabled} onChange={(e) => set({ monthly: Number(e.target.value) })} style={sel}>
91
+ <select value={limits.monthly} disabled={disabled} onChange={(e) => ask({ monthly: Number(e.target.value) })} style={sel}>
84
92
  {MONTHLY.map((n) => <option key={n} value={n}>{n ? `${n} a month` : 'no monthly cap (Premium)'}</option>)}
85
93
  </select>
86
94
  <select value={limits.profiles} disabled={disabled} onChange={(e) => set({ profiles: Number(e.target.value) })} style={sel}>
@@ -91,8 +99,17 @@ export function BudgetBox({ li, onSetLimits, disabled }) {
91
99
  Every page of someone&rsquo;s connections is one search. LinkedIn limits a free account&rsquo;s people
92
100
  searches by the month (it doesn&rsquo;t say how many; reports put it around 250–350), resetting on the 1st.
93
101
  When a budget is used, a scan saves what it read and stops; the next one carries on from the same page.
94
- {limits.daily > 100 && <b style={{ color: '#FFD700' }}> {limits.daily} a day can use up a free account&rsquo;s month in a day or two.</b>}
95
102
  </div>
103
+ {note && (
104
+ <div style={{ marginTop: 8, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', color: '#FFD700', lineHeight: 1.6 }}>
105
+ <b style={{ flex: 1, minWidth: 200 }}>{note}</b>
106
+ <button
107
+ onClick={() => set(SAFE_LIMITS)}
108
+ disabled={disabled}
109
+ style={{ ...sel, cursor: disabled ? 'not-allowed' : 'pointer', color: '#FFD700', borderColor: 'rgba(255,215,0,0.4)' }}
110
+ >Back to {SAFE_LIMITS.daily} a day, {SAFE_LIMITS.monthly} a month</button>
111
+ </div>
112
+ )}
96
113
  <div style={{ marginTop: 6, color: '#778', lineHeight: 1.6 }}>
97
114
  Each circle scan opens the person&rsquo;s profile once, which is one profile view. Profile views are what
98
115
  LinkedIn restricted an account for, after about 20 in an hour. The scanner opens at most one a minute, and
@@ -213,49 +213,108 @@ const OrbitGraph = forwardRef(function OrbitGraph(
213
213
  const links = [];
214
214
  let sceneRadius = TIER_RING_RADIUS.B + 70;
215
215
  if (degreesMode) {
216
- // One ring of the people you've mapped, biggest circle first, each given
217
- // an equal wedge. Their circle fans outward in arcs: the most powerful
218
- // closest to them, dot size and colour by their own power and tier.
219
- const ordered = [...d1Nodes].sort((a, b) =>
220
- (clusters.get(b.id)?.length || 0) - (clusters.get(a.id)?.length || 0));
216
+ // Someone you added through a circle, whose own circle is scanned, sits in
217
+ // the fan of the circle they came from, and their people fan out behind
218
+ // them a band further out, and theirs behind them, to 6th degree (Blake,
219
+ // 2026-09-29: Orbit stopped at each connection's 2nd degree).
220
+ const nestedIn = new Map();
221
+ const nested = new Set();
222
+ for (const d of d1Nodes) {
223
+ const from = d.connection?.unlocked_from_bridge_id;
224
+ if (from == null || from === d.id || !d1NodeById.has(from)) continue;
225
+ nested.add(d.id);
226
+ const list = nestedIn.get(from);
227
+ if (list) list.push(d); else nestedIn.set(from, [d]);
228
+ }
229
+ // Everyone reachable from someone on the ring is drawn in a fan; anyone
230
+ // else (a loop of introductions) goes on the ring.
231
+ const onRing = d1Nodes.filter((d) => !nested.has(d.id));
232
+ const inFans = new Set();
233
+ const walk = (d) => { for (const p of nestedIn.get(d.id) || []) if (!inFans.has(p.id)) { inFans.add(p.id); walk(p); } };
234
+ onRing.forEach(walk);
235
+ for (const d of d1Nodes) if (nested.has(d.id) && !inFans.has(d.id)) onRing.push(d);
236
+ const behind = new Map();
237
+ const reachOf = (d, stack = new Set()) => {
238
+ if (behind.has(d.id)) return behind.get(d.id);
239
+ if (stack.has(d.id)) return 0;
240
+ stack.add(d.id);
241
+ let count = clusters.get(d.id)?.length || 0;
242
+ for (const p of nestedIn.get(d.id) || []) count += 1 + reachOf(p, stack);
243
+ stack.delete(d.id);
244
+ behind.set(d.id, count);
245
+ return count;
246
+ };
247
+
248
+ // One ring of the people you've mapped, biggest reach first, each given
249
+ // a wedge; their circle fans outward in arcs, the most powerful closest.
250
+ const ordered = [...onRing].sort((a, b) => reachOf(b) - reachOf(a));
221
251
  const n = Math.max(1, ordered.length);
222
252
  const R = Math.max(140, n * 9);
223
253
  // Room in proportion to how many are behind each person (square-rooted,
224
254
  // so one huge circle can't take the whole ring): big circles spread into
225
255
  // fans instead of shooting out as thin spikes.
226
- const weight = (d) => Math.sqrt((clusters.get(d.id)?.length || 0) + 4);
256
+ const weight = (d) => Math.sqrt(reachOf(d) + 4);
227
257
  const total = ordered.reduce((sum, d) => sum + weight(d), 0) || 1;
228
- let cursor = -Math.PI / 2;
229
- ordered.forEach((b) => {
230
- const share = (weight(b) / total) * Math.PI * 2;
231
- const angle = cursor + share / 2;
232
- cursor += share;
233
- const wedge = Math.min(share * 0.88, 1.6);
234
- b.x = b.fx = Math.cos(angle) * R;
235
- b.y = b.fy = Math.sin(angle) * R;
236
- b.hub = true;
237
- b.r = 9 + Math.min(10, (clusters.get(b.id)?.length || 0) / 40);
238
- const people = [...(clusters.get(b.id) || [])]
239
- .sort((p, q) => (Number(q.power_score) || 0) - (Number(p.power_score) || 0));
258
+
259
+ // `owner`'s people in rows across `wedge`, from rStart out; someone they
260
+ // added gets their own narrower fan behind them. Returns the outer edge.
261
+ const placeFan = (owner, angle, wedge, rStart, depth) => {
262
+ const people = [
263
+ ...(clusters.get(owner.id) || []).map((c) => ({ c })),
264
+ ...(nestedIn.get(owner.id) || []).filter((p) => inFans.has(p.id)).map((p) => ({ added: p, c: p.connection })),
265
+ ].sort((p, q) => (Number(q.c?.power_score) || 0) - (Number(p.c?.power_score) || 0));
266
+ const far = depth > 2;
267
+ const step = far ? 8 : 9;
268
+ const subs = [];
240
269
  let placed = 0;
241
- let r = R + b.r + 16;
270
+ let r = rStart;
242
271
  while (placed < people.length) {
243
- const fit = Math.max(1, Math.floor((r * wedge) / 9));
272
+ const fit = Math.max(1, Math.floor((r * wedge) / step));
244
273
  const row = people.slice(placed, placed + fit);
245
- row.forEach((c, j) => {
274
+ row.forEach((p, j) => {
246
275
  const a = angle + (row.length === 1 ? 0 : (j / (row.length - 1) - 0.5) * wedge);
276
+ const x = Math.cos(a) * r;
277
+ const y = Math.sin(a) * r;
278
+ if (p.added) {
279
+ const d = p.added;
280
+ d.x = d.fx = x; d.y = d.fy = y;
281
+ d.hub = false; d.added = true;
282
+ d.r = 5 + Math.min(5, reachOf(d) / 60);
283
+ subs.push({ d, a });
284
+ return;
285
+ }
247
286
  const node = {
248
- id: c.id, kind: 'd2', tier: c.tier || 'D',
249
- r: 1.8 + Math.min(10, Number(c.power_score) || 0) * 0.32,
250
- connection: c, x: Math.cos(a) * r, y: Math.sin(a) * r,
287
+ id: p.c.id, kind: 'd2', depth, tier: p.c.tier || 'D',
288
+ r: (far ? 1.3 : 1.8) + Math.min(10, Number(p.c.power_score) || 0) * (far ? 0.24 : 0.32),
289
+ connection: p.c, x, y,
251
290
  };
252
291
  node.fx = node.x; node.fy = node.y;
253
292
  d2Nodes.push(node);
293
+ if (owner.added) links.push({ source: owner, target: node, faint: true });
254
294
  });
255
295
  placed += row.length;
256
- r += 9;
296
+ r += step;
257
297
  }
258
- sceneRadius = Math.max(sceneRadius, r + 10);
298
+ let edge = r;
299
+ for (const { d, a } of subs) {
300
+ if (depth >= 6 || !reachOf(d)) continue;
301
+ const sub = Math.min(wedge * 0.6, Math.max(0.12, (Math.min(reachOf(d), 60) * 8) / (r + 24)));
302
+ edge = Math.max(edge, placeFan(d, a, sub, r + 24, depth + 1));
303
+ }
304
+ return edge;
305
+ };
306
+
307
+ let cursor = -Math.PI / 2;
308
+ ordered.forEach((b) => {
309
+ const share = (weight(b) / total) * Math.PI * 2;
310
+ const angle = cursor + share / 2;
311
+ cursor += share;
312
+ const wedge = Math.min(share * 0.88, 1.6);
313
+ b.x = b.fx = Math.cos(angle) * R;
314
+ b.y = b.fy = Math.sin(angle) * R;
315
+ b.hub = true;
316
+ b.r = 9 + Math.min(10, (clusters.get(b.id)?.length || 0) / 40);
317
+ sceneRadius = Math.max(sceneRadius, placeFan(b, angle, wedge, R + b.r + 16, 2) + 10);
259
318
  });
260
319
  }
261
320
  for (const [bridgeId, cluster] of degreesMode ? [] : clusters) {
@@ -324,7 +383,8 @@ const OrbitGraph = forwardRef(function OrbitGraph(
324
383
  }
325
384
 
326
385
  const link = linksLayer.selectAll('line').data(links).join('line')
327
- .attr('stroke', 'rgba(255,255,255,0.08)').attr('stroke-width', 1);
386
+ .attr('stroke', (l) => (l.faint ? 'rgba(0,255,136,0.12)' : 'rgba(255,255,255,0.08)'))
387
+ .attr('stroke-width', (l) => (l.faint ? 0.5 : 1));
328
388
 
329
389
  const node = nodesLayer.selectAll('g').data(renderNodes, (d) => d.id).join('g')
330
390
  .attr('cursor', 'pointer');
@@ -333,7 +393,7 @@ const OrbitGraph = forwardRef(function OrbitGraph(
333
393
  const g = select(this);
334
394
 
335
395
  if (d.kind === 'd2') {
336
- g.attr('opacity', degreesMode ? 0.85 : 0.55);
396
+ g.attr('opacity', degreesMode ? (d.depth > 2 ? 0.6 : 0.85) : 0.55);
337
397
  g.append('circle').attr('class', 'og-base').attr('r', d.r)
338
398
  .attr('fill', TIER_COLORS[d.tier] || TIER_COLORS.D);
339
399
  return; // selection ring is created on demand, see applySelection
@@ -347,8 +407,10 @@ const OrbitGraph = forwardRef(function OrbitGraph(
347
407
  // selecting promotes it — the tooltip carries the detail, so nothing is
348
408
  // hidden, it is just not all shouted at once.
349
409
  if (!isUser && !d.hub) {
350
- g.attr('opacity', 0.75);
351
- g.append('circle').attr('class', 'og-base').attr('r', d.r).attr('fill', color);
410
+ g.attr('opacity', d.added ? 1 : 0.75);
411
+ // Someone you added through a circle: green-ringed, with their own fan behind them.
412
+ g.append('circle').attr('class', 'og-base').attr('r', d.r).attr('fill', color)
413
+ .attr('stroke', d.added ? '#00ff88' : 'none').attr('stroke-width', d.added ? 1.5 : 0);
352
414
  return; // selection ring is created on demand, see applySelection
353
415
  }
354
416
 
@@ -11,6 +11,7 @@ import { useUser } from './UserProvider';
11
11
  import { routeIndex, routesFor } from '../../lib/separation';
12
12
  import { topCompanies } from '../../lib/scoring';
13
13
  import { reachIndex, reachState, circleState, readyToScan, circleScanCost } from '../../lib/reach';
14
+ import { exclusiveReach, bridgeOverlap } from '../../lib/brokerage';
14
15
  import Avatar from './Avatar';
15
16
  import { localPhoto } from '../../lib/photos';
16
17
 
@@ -39,6 +40,9 @@ export default function Sidebar({ selected, stats, tierColors, connections, degr
39
40
  // shows even while a tier chip is narrowing the view, and a person picked in
40
41
  // Chains or Galaxy gets every route too.
41
42
  const routeIdx = useMemo(() => routeIndex(degree2), [degree2]);
43
+ // Who only each connection reaches (lib/brokerage.js), for their card.
44
+ const exclusive = useMemo(() => exclusiveReach(degree2, connections), [degree2, connections]);
45
+ const overlap = useMemo(() => bridgeOverlap(degree2, connections), [degree2, connections]);
42
46
  const bridgeById = useMemo(() => new Map(connections.map(c => [c.id, c])), [connections]);
43
47
  // Who you've asked, shared with every view: a request sent here shows in
44
48
  // Separation, the circle and the Outlink queue at once (lib/requests-client.js).
@@ -139,6 +143,39 @@ export default function Sidebar({ selected, stats, tierColors, connections, degr
139
143
  />
140
144
  )}
141
145
 
146
+ {selected.degree !== 2 && (exclusive.get(selected.id)?.only > 0 || (overlap.get(selected.id)?.share >= 0.15 && overlap.get(selected.id)?.shared >= 3)) && (() => {
147
+ const ex = exclusive.get(selected.id);
148
+ const ov = overlap.get(selected.id);
149
+ // A twin only when it's real: at least 15% of either's people, and 3 of them.
150
+ const twin = ov && ov.share >= 0.15 && ov.shared >= 3 && connections.find((c) => c.id === ov.with);
151
+ const first = selected.name?.split(' ')[0] || 'them';
152
+ return (
153
+ <div style={{
154
+ background: 'rgba(0,255,136,0.05)', border: '1px solid rgba(0,255,136,0.2)',
155
+ borderRadius: 8, padding: 12, marginBottom: 16,
156
+ }}>
157
+ <div style={{ fontSize: 10, color: '#00ff88', fontWeight: 700, marginBottom: 4, letterSpacing: 1 }}>
158
+ {ex?.only > 0 ? `ONLY THROUGH ${first.toUpperCase()}` : `${first.toUpperCase()}’S CIRCLE`}
159
+ </div>
160
+ {ex?.only > 0 && (
161
+ <div style={{ fontSize: 12, color: '#ccc', lineHeight: 1.5 }}>
162
+ <strong style={{ color: '#00ff88' }}>{ex.only.toLocaleString()}</strong> of the {ex.total.toLocaleString()} people in {first}&rsquo;s
163
+ circle are reached by none of your other connections. {first} is your only way to them.
164
+ </div>
165
+ )}
166
+ {twin && (
167
+ <div style={{ fontSize: 12, color: '#aab', lineHeight: 1.5, marginTop: ex?.only > 0 ? 6 : 0 }}>
168
+ Opens the same doors as{' '}
169
+ <button onClick={() => onSelect?.(twin)} style={{ background: 'none', border: 'none', padding: 0, color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: 12 }}>
170
+ {twin.name}
171
+ </button>
172
+ : {Math.round(ov.share * 100)}% of the people either reaches, both do ({ov.shared.toLocaleString()}).
173
+ </div>
174
+ )}
175
+ </div>
176
+ );
177
+ })()}
178
+
142
179
  {selected.is_catalyst && (
143
180
  <div style={{
144
181
  background: 'rgba(0,255,136,0.08)', border: '1px solid rgba(0,255,136,0.3)',
@@ -27,7 +27,7 @@ import SeparationView from './SeparationView';
27
27
  */
28
28
  export const VIEWS = {
29
29
  galaxy: { component: ForceGraph, modes: ['network'], label: 'Galaxy', icon: '🌌', desc: 'Force-directed layout' },
30
- orbit: { component: OrbitGraph, modes: ['network', 'degrees'], label: 'Orbit', icon: '🪐', desc: 'Tier orbits + circle dots; in Degrees, every mapped circle fanned out', allDegree2: true },
30
+ orbit: { component: OrbitGraph, modes: ['degrees'], label: 'Orbit', icon: '🪐', desc: 'Tier orbits + circle dots; in Degrees, every mapped circle fanned out', allDegree2: true },
31
31
  separation: { component: SeparationView, modes: ['degrees'], label: 'Separation', icon: '🏆', desc: 'Every 2nd degree, ranked · every way in' },
32
32
  chain: { component: ChainView, modes: ['degrees'], label: 'Bridge Chains', icon: '🔗', desc: 'Degree paths' },
33
33
  rings: { component: RingsView, modes: ['network', 'degrees'], label: 'Pyramid', icon: '🔺', desc: 'Tier hierarchy' },
@@ -36,11 +36,13 @@ export const VIEWS = {
36
36
  };
37
37
 
38
38
  /**
39
- * The order they appear in the menu. Orbit is the first Degrees view, so it is
40
- * also where resolveView lands when the chosen view isn't a Degrees one
41
- * (Galaxy, say, carried over from Network Circle).
39
+ * The order they appear in the menu. Bridge Chains is the first Degrees view, so
40
+ * it is also where resolveView lands when the chosen view isn't a Degrees one
41
+ * (Galaxy, say, carried over from Network Circle); then Separation, then Orbit.
42
+ * Orbit is a Degrees view only: in Network Circle it was slow and said nothing
43
+ * the Galaxy doesn't (OrbitGraph still has that branch until Orbit is redrawn).
42
44
  */
43
- const ORDER = ['galaxy', 'orbit', 'separation', 'chain', 'rings', 'list'];
45
+ const ORDER = ['galaxy', 'chain', 'separation', 'orbit', 'rings', 'list'];
44
46
 
45
47
  export function viewsForMode(mode) {
46
48
  return ORDER
Binary file