six-degrees 0.4.0 → 0.4.1

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 (189) 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.html +1 -1
  75. package/.next/standalone/.next/server/app/profile.rsc +2 -2
  76. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +2 -2
  77. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
  78. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +1 -1
  79. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  80. package/.next/standalone/.next/server/app/queue.html +1 -1
  81. package/.next/standalone/.next/server/app/queue.rsc +2 -2
  82. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +2 -2
  83. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
  84. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +1 -1
  85. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  86. package/.next/standalone/.next/server/app/scores.html +1 -1
  87. package/.next/standalone/.next/server/app/scores.rsc +2 -2
  88. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +2 -2
  89. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
  90. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +1 -1
  91. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  92. package/.next/standalone/.next/server/app/settings.html +1 -1
  93. package/.next/standalone/.next/server/app/settings.rsc +2 -2
  94. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +2 -2
  95. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
  96. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +1 -1
  97. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  98. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  99. package/.next/standalone/.next/server/app/setup.html +1 -1
  100. package/.next/standalone/.next/server/app/setup.rsc +3 -3
  101. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +3 -3
  102. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
  103. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +2 -2
  104. package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e0fei3._.js +1 -1
  105. package/.next/standalone/.next/server/chunks/{[root-of-the-server]__03ydxxh._.js → [root-of-the-server]__20tmem7._.js} +1 -1
  106. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  107. package/.next/standalone/.next/server/chunks/_1iwkh8t._.js +1 -1
  108. package/.next/standalone/.next/server/chunks/_1mxn_lb._.js +1 -1
  109. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +21 -7
  110. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00d8snt._.js +1 -1
  111. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00g61b8._.js +1 -1
  112. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08lf3gy._.js +1 -1
  113. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hg5gib._.js +1 -1
  114. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ho-u0w._.js +1 -1
  115. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ze473p._.js +1 -1
  116. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1194jps._.js +1 -1
  117. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1cnxxc9._.js +1 -1
  118. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ll97ry._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m0255u._.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1w9z0zv._.js +1 -1
  121. package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +3 -3
  122. package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +1 -1
  123. package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +6 -2
  124. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  125. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  126. package/.next/standalone/.next/server/pages/404.html +1 -1
  127. package/.next/standalone/.next/server/pages/500.html +1 -1
  128. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  129. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  130. package/.next/standalone/.next/static/chunks/10784r7w7t5gw.js +18 -0
  131. package/.next/standalone/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
  132. package/.next/standalone/.next/static/chunks/3ol1xux1-ofm4.js +6 -0
  133. package/.next/standalone/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
  134. package/.next/standalone/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
  135. package/.next/standalone/CHANGELOG.md +59 -0
  136. package/.next/standalone/app/api/admin-delete/route.js +3 -0
  137. package/.next/standalone/app/api/ingest/route.js +13 -0
  138. package/.next/standalone/app/components/ChainView.js +68 -14
  139. package/.next/standalone/app/components/FilterPanel.js +37 -1
  140. package/.next/standalone/app/components/ForceGraph.js +44 -5
  141. package/.next/standalone/app/components/LinkedInLimits.js +20 -3
  142. package/.next/standalone/app/components/Sidebar.js +37 -0
  143. package/.next/standalone/app/components/views.js +7 -5
  144. package/.next/standalone/app/favicon.ico +0 -0
  145. package/.next/standalone/app/page.js +24 -7
  146. package/.next/standalone/app/paths/page.js +8 -1
  147. package/.next/standalone/db/schema.js +14 -0
  148. package/.next/standalone/lib/brokerage.js +94 -0
  149. package/.next/standalone/lib/chain-layout.js +89 -0
  150. package/.next/standalone/lib/circle.js +23 -4
  151. package/.next/standalone/lib/degrees.js +30 -0
  152. package/.next/standalone/lib/ingest.js +3 -3
  153. package/.next/standalone/lib/linkedin-limits.js +9 -4
  154. package/.next/standalone/lib/search-risk.js +38 -0
  155. package/.next/standalone/lib/ties.js +23 -0
  156. package/.next/standalone/package-lock.json +2 -2
  157. package/.next/standalone/package.json +1 -1
  158. package/.next/standalone/scripts/scrape.py +40 -12
  159. package/.next/static/chunks/10784r7w7t5gw.js +18 -0
  160. package/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
  161. package/.next/static/chunks/3ol1xux1-ofm4.js +6 -0
  162. package/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
  163. package/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
  164. package/CHANGELOG.md +59 -0
  165. package/db/schema.js +14 -0
  166. package/lib/brokerage.js +94 -0
  167. package/lib/chain-layout.js +89 -0
  168. package/lib/circle.js +23 -4
  169. package/lib/degrees.js +30 -0
  170. package/lib/ingest.js +3 -3
  171. package/lib/linkedin-limits.js +9 -4
  172. package/lib/search-risk.js +38 -0
  173. package/lib/ties.js +23 -0
  174. package/package.json +1 -1
  175. package/scripts/scrape.py +40 -12
  176. package/.next/standalone/.next/static/chunks/0w76iraw7ij3k.js +0 -18
  177. package/.next/standalone/.next/static/chunks/135n87ydxjrra.js +0 -2
  178. package/.next/standalone/.next/static/chunks/3qh78g2aqhprd.js +0 -2
  179. package/.next/standalone/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
  180. package/.next/static/chunks/0w76iraw7ij3k.js +0 -18
  181. package/.next/static/chunks/135n87ydxjrra.js +0 -2
  182. package/.next/static/chunks/3qh78g2aqhprd.js +0 -2
  183. package/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
  184. /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_buildManifest.js +0 -0
  185. /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_clientMiddlewareManifest.js +0 -0
  186. /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_ssgManifest.js +0 -0
  187. /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_buildManifest.js +0 -0
  188. /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_clientMiddlewareManifest.js +0 -0
  189. /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_ssgManifest.js +0 -0
@@ -32,7 +32,8 @@ import { useRouter } from 'next/navigation';
32
32
  import { circleIndex } from '../../lib/circle';
33
33
  import { localPhoto } from '../../lib/photos';
34
34
  import { reachIndex, reachState, circleState, readyByCircle, circleScanCost } from '../../lib/reach';
35
- import { ringLayout, dotRadius } from '../../lib/chain-layout';
35
+ import { ringLayout, dotRadius, previewBand, tierBandLayout, outerFans } from '../../lib/chain-layout';
36
+ import { exclusiveReach, redundancy } from '../../lib/brokerage';
36
37
  import { keyFor, score } from '../../lib/separation';
37
38
  import { hasRequest } from '../../lib/requests-client';
38
39
  import { watchScanner, scannerNow, isCircleScan } from '../../lib/scraper-client';
@@ -138,6 +139,9 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
138
139
  const index = useMemo(() => circleIndex(all1, all2), [all1, all2]);
139
140
  const reach = useMemo(() => reachIndex(all1, all2, scanNotes), [all1, all2, scanNotes]);
140
141
  const readyCount = useMemo(() => readyByCircle(reach), [reach]);
142
+ // Who only this bridge reaches, across every circle scanned (lib/brokerage.js).
143
+ const exclusive = useMemo(() => exclusiveReach(fullDegree2 || degree2, fullDegree1 || connections), [fullDegree2, degree2, fullDegree1, connections]);
144
+ const twoWays = useMemo(() => redundancy(fullDegree2 || degree2, fullDegree1 || connections), [fullDegree2, degree2, fullDegree1, connections]);
141
145
  const rowById = useMemo(() => {
142
146
  const m = new Map();
143
147
  for (const r of all2) m.set(r.id, r);
@@ -271,7 +275,7 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
271
275
  {/* Their circle, previewed on hover, in a wedge that grows rows as it fills */}
272
276
  {hovBridge && (
273
277
  <CirclePreview bridge={hovBridge} members={membersOf(hovBridge, index)} reach={reach}
274
- cx={cx} cy={cy} maxR={maxR} still={still} />
278
+ cx={cx} cy={cy} maxR={maxR} still={still} band={previewBand(maxR, bridgeLayout.rings)} />
275
279
  )}
276
280
 
277
281
  {/* Bridge nodes — hover to preview their circle, click to open it */}
@@ -325,6 +329,12 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
325
329
  <text x={b.x} y={b.y + (isHov ? 34 : 32)} textAnchor="middle" fill="#888" fontSize={7}>
326
330
  {b.clusterSize} · {sCount > 0 ? sCount + 'S ' : ''}{aCount > 0 ? aCount + 'A' : ''}
327
331
  </text>
332
+ {exclusive.get(b.id)?.only > 0 && (
333
+ <text x={b.x} y={b.y + (isHov ? 43 : 41)} textAnchor="middle" fill="#00ff88" fillOpacity={0.75} fontSize={6.5}>
334
+ {exclusive.get(b.id).only.toLocaleString()} only here
335
+ <title>{`${exclusive.get(b.id).only.toLocaleString()} of the people in ${b.name?.split(' ')[0]}’s circle are reached by none of your other connections`}</title>
336
+ </text>
337
+ )}
328
338
  </g>
329
339
  );
330
340
  })}
@@ -372,6 +382,12 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
372
382
  <div style={{ fontSize: 9, color: '#555', marginTop: 8 }}>
373
383
  {bridges.length} bridges · {d2S} S + {d2A} A at 2nd degree
374
384
  </div>
385
+ {twoWays > 0 && (
386
+ <div style={{ fontSize: 9, color: '#555', marginTop: 4 }}
387
+ title="The share of everyone you reach through a circle that more than one of your connections reaches">
388
+ {Math.round(twoWays * 100)}% of your 2nd degree you reach two or more ways
389
+ </div>
390
+ )}
375
391
  <div style={{ fontSize: 8, color: '#444', marginTop: 4 }}>Click a bridge to open their circle, then anyone in it to open theirs</div>
376
392
  </div>
377
393
  </div>
@@ -379,13 +395,13 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
379
395
  }
380
396
 
381
397
  /** 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 }) {
398
+ function CirclePreview({ bridge, members, reach, cx, cy, maxR, still, band }) {
383
399
  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)));
400
+ // Beyond every ring of bridges (lib/chain-layout.js previewBand).
401
+ const { inner, outer } = band;
402
+ const sweep = Math.min(Math.PI * 0.9, Math.max(Math.PI * 0.2, (shown.length * 11) / inner));
387
403
  const layout = ringLayout(shown.length, {
388
- inner, outer: maxR * 0.8, spacing: 11, minSpacing: 3.5, start: bridge.angle - sweep / 2, sweep,
404
+ inner, outer, spacing: 11, minSpacing: 3.5, start: bridge.angle - sweep / 2, sweep,
389
405
  });
390
406
  return (
391
407
  <g>
@@ -473,10 +489,25 @@ function CircleFocus({ trail, index, reach, dims, requests, scanningId, still, c
473
489
  const cx = dims.w / 2;
474
490
  const cy = dims.h / 2;
475
491
  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]);
492
+ // Whose own circle is known, drawn behind them (their people, one degree
493
+ // further out): anyone you're connected to whose circle was scanned, or who
494
+ // introduced you to someone.
495
+ const behind = useMemo(() => members.map((row) => (row.degree === 1 ? membersOf(row, index) : [])), [members, index]);
496
+ const hasFans = behind.some((people) => people.length > 0);
497
+ // Tier bands round them, S nearest, like Network Circle's orbits; with fans
498
+ // to draw, the bands leave the outside of the circle for them.
499
+ const layout = useMemo(() => tierBandLayout(members, {
500
+ inner: Math.max(62, maxR * 0.3), outer: maxR * (hasFans ? 0.72 : 0.94),
501
+ spacing: Math.max(14, Math.min(24, maxR * 0.085)), minSpacing: 4, gap: Math.max(10, maxR * 0.04),
502
+ }), [members, maxR, hasFans]);
503
+ const fans = useMemo(() => {
504
+ if (!hasFans) return [];
505
+ const placed = outerFans(
506
+ members.map((row, i) => ({ angle: layout.points[i].angle, slot: layout.points[i].slot, count: behind[i].length })),
507
+ { from: layout.edge + layout.spacing * 1.5 },
508
+ );
509
+ return placed.map((f, i) => f && { ...f, i, people: behind[i].slice(0, f.points.length) }).filter(Boolean);
510
+ }, [members, behind, hasFans, layout]);
480
511
 
481
512
  // What each dot is: someone you reached (ready, hidden, scanned) or a
482
513
  // 2nd-degree row, maybe with a request out; and how big their own circle is.
@@ -608,10 +639,33 @@ function CircleFocus({ trail, index, reach, dims, requests, scanningId, still, c
608
639
  <filter id="focusGlow"><feGaussianBlur stdDeviation="4" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
609
640
  </defs>
610
641
  <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} />
642
+ {/* A faint band behind each tier's dots, in its colour */}
643
+ {layout.bands.map((band) => (
644
+ <circle key={band.tier} r={(band.inner + band.outer) / 2} fill="none"
645
+ stroke={TIER_COLORS[band.tier] || '#555'} strokeOpacity={0.07}
646
+ strokeWidth={Math.max(1 / k, band.outer - band.inner + layout.spacing)} pointerEvents="none" />
614
647
  ))}
648
+ {/* Behind anyone whose circle is known: their people, a faint line to each */}
649
+ {fans.map((fan) => {
650
+ const a = layout.points[fan.i];
651
+ const last = fan.points[fan.points.length - 1];
652
+ return (
653
+ <g key={'fan-' + members[fan.i].id} pointerEvents="none">
654
+ {fan.points.map((p, j) => (
655
+ <line key={'l' + j} x1={a.x} y1={a.y} x2={p.x} y2={p.y}
656
+ stroke={GREEN} strokeOpacity={0.12} strokeWidth={0.5 / k} />
657
+ ))}
658
+ {fan.points.map((p, j) => (
659
+ <circle key={'d' + j} cx={p.x} cy={p.y} r={2.2}
660
+ fill={TIER_COLORS[fan.people[j]?.tier] || '#555'} fillOpacity={0.75} />
661
+ ))}
662
+ {fan.more > 0 && last && (
663
+ <text x={last.x * 1.06} y={last.y * 1.06} textAnchor="middle" fontSize={8 / k} fill="#8b9a9a"
664
+ stroke="#0a0a1a" strokeWidth={2.4 / k} paintOrder="stroke">+{fan.more.toLocaleString()}</text>
665
+ )}
666
+ </g>
667
+ );
668
+ })}
615
669
  {dots}
616
670
  {/* The one under the pointer, on top */}
617
671
  {hp && (
@@ -12,7 +12,7 @@ function useIsMobile() {
12
12
  const TIER_COLORS = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
13
13
 
14
14
 
15
- export default function FilterPanel({ collapsed, onToggle, mode, filter, onFilterChange, visualMode, onVisualModeChange, tierCounts, bridgeTierCounts }) {
15
+ export default function FilterPanel({ collapsed, onToggle, mode, filter, onFilterChange, visualMode, onVisualModeChange, tierCounts, bridgeTierCounts, degrees = [1], onDegreesChange, degreeCounts = {} }) {
16
16
  const isMobile = useIsMobile();
17
17
  const isDegreesMode = mode === 'degrees';
18
18
  // Same source of truth the renderer uses, so the menu can never offer a
@@ -105,6 +105,42 @@ export default function FilterPanel({ collapsed, onToggle, mode, filter, onFilte
105
105
  </div>
106
106
 
107
107
  {/* Tier Filters */}
108
+ {/* Network Circle: which degrees to draw. Any mix, never none: 2nd and
109
+ 3rd without your own connections shows who's valuable outside them. */}
110
+ {!isDegreesMode && onDegreesChange && (
111
+ <div style={{ marginBottom: 18 }}>
112
+ <div style={{ fontSize: 9, fontWeight: 700, color: '#555', letterSpacing: 1, marginBottom: 8, textTransform: 'uppercase' }}>
113
+ Degree
114
+ </div>
115
+ <div style={{ display: 'flex', gap: 6 }}>
116
+ {[[1, '1st', 'Your connections'], [2, '2nd', 'In a scanned circle'], [3, '3rd', 'Found by a company scan']].map(([d, label, what]) => {
117
+ const on = degrees.includes(d);
118
+ const count = degreeCounts[d] || 0;
119
+ const last = on && degrees.length === 1;
120
+ return (
121
+ <button
122
+ key={d}
123
+ title={`${what}: ${count.toLocaleString()}${last ? ' (keep at least one)' : ''}`}
124
+ aria-pressed={on}
125
+ disabled={last || (!on && !count)}
126
+ onClick={() => onDegreesChange(on ? degrees.filter((x) => x !== d) : [...degrees, d].sort())}
127
+ style={{
128
+ flex: 1, padding: '7px 4px', borderRadius: 8, fontSize: 11.5, fontWeight: 700,
129
+ cursor: last || (!on && !count) ? 'default' : 'pointer',
130
+ border: on ? '1px solid rgba(52,152,219,0.6)' : '1px solid rgba(255,255,255,0.08)',
131
+ background: on ? 'rgba(52,152,219,0.18)' : 'rgba(255,255,255,0.03)',
132
+ color: on ? '#cfe6f7' : count ? '#888' : '#444',
133
+ }}
134
+ >
135
+ {label}
136
+ <div style={{ fontSize: 9.5, fontWeight: 500, opacity: 0.8, marginTop: 2 }}>{count.toLocaleString()}</div>
137
+ </button>
138
+ );
139
+ })}
140
+ </div>
141
+ </div>
142
+ )}
143
+
108
144
  <div style={{ marginBottom: 18 }}>
109
145
  {/* In Degrees these chips filter by the tier of the connection who
110
146
  introduces you, not the person reached — Separation has its own
@@ -207,7 +207,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
207
207
  };
208
208
 
209
209
  const nodes = [centerNode, ...connections.map(c => ({
210
- id: c.id, name: c.name, tier: c.tier, degree: 1,
210
+ id: c.id, name: c.name, tier: c.tier, degree: c.degree || 1, source_connection_id: c.source_connection_id,
211
211
  power_score: parseFloat(c.power_score) || 1,
212
212
  company: c.company, role: c.role, headline: c.headline,
213
213
  profile_url: c.profile_url, profile_image_url: localPhoto(c.profile_image_url),
@@ -219,7 +219,14 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
219
219
  }))];
220
220
  const nodeById = new Map(nodes.map(n => [n.id, n]));
221
221
 
222
- const links = connections.map(c => ({ source: CENTER_ID, target: c.id }));
222
+ // Your connections hang off you; someone further out (Filter → Degree) off the
223
+ // connection whose circle they're in, when that one is drawn too, so they
224
+ // gather near them.
225
+ const drawnD1 = new Set(connections.filter(c => (c.degree || 1) === 1).map(c => c.id));
226
+ const links = connections.map(c => {
227
+ const via = (c.degree || 1) > 1 && drawnD1.has(c.source_connection_id) ? c.source_connection_id : CENTER_ID;
228
+ return { source: via, target: c.id, near: via !== CENTER_ID };
229
+ });
223
230
 
224
231
  const tierRadius = (tier) => {
225
232
  switch (tier) { case 'S': return 150; case 'A': return 250; case 'B': return 350; case 'C': return 450; default: return 520; }
@@ -227,7 +234,8 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
227
234
 
228
235
  const nodeRadius = (d) => {
229
236
  if (d.id === CENTER_ID) return 18;
230
- return Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
237
+ const r = Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
238
+ return d.degree > 1 ? Math.max(2.5, r * 0.75) : r; // further out, a little smaller
231
239
  };
232
240
 
233
241
  // A phone. Judged by the window, as the page judges it, not by the graph's
@@ -235,6 +243,25 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
235
243
  const isMobileGraph = window.innerWidth < 768;
236
244
 
237
245
  const g = svg.append('g');
246
+
247
+ // Faint S–D guide rings where each tier settles (the radial force below), so
248
+ // the tier bands read at a glance. Not on a phone, whose layout has no rings.
249
+ // Once the layout settles they move to where each tier's dots actually sit
250
+ // (the median distance out), which the other forces push past that radius.
251
+ const guideRings = new Map();
252
+ if (!isMobileGraph) {
253
+ const guides = g.append('g').attr('class', 'tier-guides').style('pointer-events', 'none');
254
+ for (const tier of ['S', 'A', 'B', 'C', 'D']) {
255
+ const r = tierRadius(tier);
256
+ const ring = guides.append('circle').attr('r', r).attr('fill', 'none')
257
+ .attr('stroke', tierColors[tier] || '#666').attr('stroke-opacity', 0.16).attr('stroke-width', 1)
258
+ .attr('stroke-dasharray', '2 4');
259
+ const label = guides.append('text').attr('x', 0).attr('y', -r - 4).attr('text-anchor', 'middle')
260
+ .attr('font-size', 10).attr('font-weight', 700).attr('fill', tierColors[tier] || '#666').attr('fill-opacity', 0.5)
261
+ .text(tier);
262
+ guideRings.set(tier, { ring, label });
263
+ }
264
+ }
238
265
  // Where the view is going while a transition takes it there. A resize part-way
239
266
  // through re-fits the destination, and a rebuild starts from it, rather than
240
267
  // from a point along the way.
@@ -330,11 +357,20 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
330
357
  }
331
358
 
332
359
  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))
360
+ .force('link', d3.forceLink(links).id(d => d.id).distance(d => (d.near ? 45 : tierRadius(d.target.tier || 'D'))).strength(isMobileGraph ? 0 : 0.1))
334
361
  .force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
335
362
  .force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
336
363
  .force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
337
364
  .force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier), 0, 0).strength(0.3));
365
+ simulation.on('end.guides', () => {
366
+ for (const [tier, { ring, label }] of guideRings) {
367
+ 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);
368
+ if (!out.length) { ring.attr('opacity', 0); label.attr('opacity', 0); continue; }
369
+ const r = out[Math.floor(out.length / 2)];
370
+ ring.attr('r', r).attr('opacity', 1);
371
+ label.attr('y', -r - 4).attr('opacity', 1);
372
+ }
373
+ });
338
374
 
339
375
  // forceLink has already swapped each link's ids for the nodes themselves, so
340
376
  // the colour is one step away. It used to search every node for every link:
@@ -364,6 +400,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
364
400
  const node = g.append('g').selectAll('circle').data(nodes).join('circle')
365
401
  .attr('r', nodeRadius)
366
402
  .attr('fill', d => d.id === CENTER_ID ? '#fff' : tierColors[d.tier] || '#666')
403
+ .attr('fill-opacity', d => (d.degree >= 3 ? 0.55 : d.degree === 2 ? 0.75 : 1))
367
404
  .attr('stroke', d => {
368
405
  if (d.id === CENTER_ID) return '#FFD700';
369
406
  if (d.is_catalyst) return '#00ff88';
@@ -425,7 +462,9 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
425
462
  setupDrag(node, simulation, CENTER_ID);
426
463
 
427
464
  const labels = g.append('g').selectAll('text')
428
- .data(nodes.filter(n => n.id === CENTER_ID || n.tier === 'S' || n.is_catalyst))
465
+ // Names for you, your S-tier connections and catalysts; further out (the
466
+ // Degree filter) only on hover, or hundreds of S-tier names pile up.
467
+ .data(nodes.filter(n => n.id === CENTER_ID || (n.degree === 1 && (n.tier === 'S' || n.is_catalyst))))
429
468
  .join('text')
430
469
  .text(d => d.is_catalyst && d.tier !== 'S' ? d.name + ' ⚡' : d.name)
431
470
  .attr('font-size', d => d.id === CENTER_ID ? 14 : 10)
@@ -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
@@ -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
@@ -8,6 +8,7 @@ import useRequests from './components/useRequests';
8
8
  import { requestCount } from '../lib/requests-client';
9
9
  import { loadNetwork } from '../lib/network';
10
10
  import { VIEWS, resolveView } from './components/views';
11
+ import { peopleByDegree, tierCountsOf } from '../lib/degrees';
11
12
  import Sidebar from './components/Sidebar';
12
13
  import FilterPanel from './components/FilterPanel';
13
14
  import OnboardingGate from './components/OnboardingGate';
@@ -68,6 +69,10 @@ function HomeInner() {
68
69
  const isMobile = useIsMobile();
69
70
  const [degree1, setDegree1] = useState([]);
70
71
  const [degree2, setDegree2] = useState([]);
72
+ // People found only by company scans: 3rd degree in Network Circle's filter.
73
+ const [degree3, setDegree3] = useState([]);
74
+ // Which degrees Network Circle draws: your connections alone until you pick more.
75
+ const [degrees, setDegrees] = useState([1]);
71
76
  const [selected, setSelected] = useState(null);
72
77
  const focusNodeRef = useRef(null);
73
78
  const [filter, setFilter] = useState('all');
@@ -102,7 +107,7 @@ function HomeInner() {
102
107
  // imported in this tab (1st-degree only, never persisted), or Supabase.
103
108
  const csv = hasCsvNetwork() ? loadCsvNetwork() : null;
104
109
  if (csv) { setCsvMode(true); setCsvSource(csv.source || 'csv'); }
105
- const { degree1: d1, degree2: d2 } = IS_DEMO
110
+ const { degree1: d1, degree2: d2, degree3: d3 = [] } = IS_DEMO
106
111
  ? await loadDemoNetwork()
107
112
  : csv
108
113
  ? csv
@@ -112,6 +117,7 @@ function HomeInner() {
112
117
 
113
118
  setDegree1(d1);
114
119
  setDegree2(d2);
120
+ setDegree3(d3 || []);
115
121
  shapeRef.current = networkShape(d1, d2);
116
122
  setStats(statsFor(d1, d2));
117
123
  setLoading(false);
@@ -131,8 +137,9 @@ function HomeInner() {
131
137
  const reload = useCallback(async () => {
132
138
  if (IS_DEMO || hasCsvNetwork() || !userId) return;
133
139
  try {
134
- const [{ degree1: d1, degree2: d2 }, notes] = await Promise.all([loadNetwork(userId), loadScanNotes()]);
140
+ const [{ degree1: d1, degree2: d2, degree3: d3 = [] }, notes] = await Promise.all([loadNetwork(userId), loadScanNotes()]);
135
141
  setScanNotes((prev) => (JSON.stringify(prev) === JSON.stringify(notes) ? prev : notes));
142
+ setDegree3((prev) => (prev.length === (d3 || []).length ? prev : d3 || []));
136
143
  const shape = networkShape(d1, d2);
137
144
  if (shape === shapeRef.current) return;
138
145
  shapeRef.current = shape;
@@ -156,9 +163,16 @@ function HomeInner() {
156
163
  // scene when these change identity: computed inline, every re-render — a click,
157
164
  // the sidebar opening, notifications arriving — reset the galaxy's layout. The
158
165
  // empty list in network mode was a new [] each time, which was enough on its own.
166
+ // Network Circle draws the degrees picked in the Filter panel, each person
167
+ // once, at the nearest degree they're found (lib/degrees.js).
168
+ const byDegree = useMemo(() => peopleByDegree(degree1, degree2, degree3), [degree1, degree2, degree3]);
169
+ const networkRows = useMemo(
170
+ () => (isDegreesMode ? connections : degrees.flatMap((d) => byDegree[d] || [])),
171
+ [isDegreesMode, connections, degrees, byDegree],
172
+ );
159
173
  const filtered = useMemo(
160
- () => (filter === 'all' ? connections : connections.filter(c => c.tier === filter)),
161
- [connections, filter],
174
+ () => (filter === 'all' ? networkRows : networkRows.filter(c => c.tier === filter)),
175
+ [networkRows, filter],
162
176
  );
163
177
  // Lookups built once per load. Every click re-renders this component, and the
164
178
  // three places below used to scan one list inside another — degree1.find per
@@ -218,7 +232,7 @@ function HomeInner() {
218
232
  {isMobile ? 'Circle' : 'Network Circle'}
219
233
  </button>
220
234
  <button
221
- onClick={() => { setMode('degrees'); setSelected(null); setFilter('all'); setVisualMode('separation'); }}
235
+ onClick={() => { setMode('degrees'); setSelected(null); setFilter('all'); setVisualMode('chain'); }}
222
236
  style={{
223
237
  padding: isMobile ? '6px 10px' : '8px 16px', borderRadius: 6, border: 'none', fontSize: isMobile ? 11 : 13, fontWeight: 600, cursor: 'pointer',
224
238
  background: mode === 'degrees' ? 'linear-gradient(135deg, #FFD700, #FF6B35)' : 'rgba(255,255,255,0.12)',
@@ -419,9 +433,12 @@ function HomeInner() {
419
433
  mode={mode}
420
434
  filter={filter}
421
435
  onFilterChange={setFilter}
422
- visualMode={visualMode}
436
+ visualMode={view.key}
423
437
  onVisualModeChange={setVisualMode}
424
- tierCounts={stats?.tiers || {}}
438
+ tierCounts={isDegreesMode ? stats?.tiers || {} : tierCountsOf(networkRows)}
439
+ degrees={degrees}
440
+ onDegreesChange={setDegrees}
441
+ degreeCounts={{ 1: byDegree[1].length, 2: byDegree[2].length, 3: byDegree[3].length }}
425
442
  bridgeTierCounts={bridgeTierCounts}
426
443
  />
427
444
  {/* Visualization — switches based on visualMode */}
@@ -43,6 +43,8 @@ function PathsInner() {
43
43
  useEffect(() => { if (asked === 'scores') router.replace('/scores#companies'); }, [asked, router]);
44
44
  // Bumped when company scores change, so the network reloads with new tiers.
45
45
  const [reloadKey, setReloadKey] = useState(0);
46
+ // The sample or a CSV import: they live only in this tab, so a company scan could only fail.
47
+ const [localOnly, setLocalOnly] = useState(false);
46
48
 
47
49
  useEffect(() => {
48
50
  if (IS_DEMO) return;
@@ -52,6 +54,7 @@ function PathsInner() {
52
54
  // in this tab, not the database. Keep the sample's 2nd degree: without it
53
55
  // the company map has no links and nobody is "reachable".
54
56
  const csv = hasCsvNetwork() ? loadCsvNetwork() : null;
57
+ setLocalOnly(Boolean(csv));
55
58
  let d1 = [], d2 = [], d3 = [];
56
59
  if (csv) {
57
60
  d1 = csv.degree1;
@@ -118,7 +121,7 @@ function PathsInner() {
118
121
  }
119
122
 
120
123
  async function scanFullCompany() {
121
- if (IS_DEMO) return;
124
+ if (IS_DEMO || localOnly) return;
122
125
  if (!selectedCompany) return;
123
126
  // Company scans shipped in 0.1.0 without ever having been run against live
124
127
  // LinkedIn (docs/brain/PHASES.md). Say so once per browser before the first
@@ -262,6 +265,9 @@ function PathsInner() {
262
265
  <span style={{ fontSize: 11, color: '#888' }}>
263
266
  {connectedCount}/{companyPeople.length} connected
264
267
  </span>
268
+ {localOnly ? (
269
+ <span style={{ marginLeft: 'auto', fontSize: 11, color: '#888' }}>Scanning needs your own network</span>
270
+ ) : (
265
271
  <button onClick={scanFullCompany} disabled={scanning || Boolean(busy)}
266
272
  title={busy ? `${busy}. One scan at a time.` : undefined}
267
273
  style={{
@@ -271,6 +277,7 @@ function PathsInner() {
271
277
  }}>
272
278
  {scanning ? 'Scanning...' : busy ? 'Scanner busy' : '+ Scan Full Company'}
273
279
  </button>
280
+ )}
274
281
  <span
275
282
  title="Not yet tested against live LinkedIn. Results may be incomplete."
276
283
  style={{
@@ -134,6 +134,20 @@ CREATE TABLE IF NOT EXISTS company_scores (
134
134
 
135
135
  -- Small key/value facts about this install, e.g. which scoring model the
136
136
  -- stored scores were computed with.
137
+ -- Two of your own connections who know each other, seen when a circle scan
138
+ -- found one of them in the other's list (they're dropped from that circle,
139
+ -- being yours already). By profile URL, the smaller first, so a pair is one
140
+ -- row whichever circle showed it. No extra LinkedIn traffic: the scan read
141
+ -- them anyway. For clustering and communities later (lib/ties.js).
142
+ CREATE TABLE IF NOT EXISTS connection_ties (
143
+ id TEXT, -- lib/db.js gives every row one
144
+ user_id TEXT NOT NULL,
145
+ a_url TEXT NOT NULL,
146
+ b_url TEXT NOT NULL,
147
+ seen_at TEXT DEFAULT (datetime('now')),
148
+ PRIMARY KEY (user_id, a_url, b_url)
149
+ );
150
+
137
151
  CREATE TABLE IF NOT EXISTS app_meta (
138
152
  key TEXT PRIMARY KEY,
139
153
  value TEXT