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
@@ -0,0 +1,94 @@
1
+ // Who is irreplaceable among your bridges.
2
+ //
3
+ // A bridge's exclusive reach: for each person in their circle, 1 ÷ how many of
4
+ // your bridges reach that person, added up. Someone only Maya reaches counts 1
5
+ // to her; someone three bridges reach counts a third to each. It's ego
6
+ // betweenness (Everett & Borgatti 2005), which on this network's shape (you →
7
+ // your connections → their circles) gives Brandes' betweenness, in one pass.
8
+ // `only` is how many nobody else reaches: "212 of your people only through
9
+ // Maya". People who are your connections already need no bridge, so they
10
+ // don't count.
11
+ //
12
+ // Plain functions, no React; tests/brokerage.test.mjs.
13
+
14
+ import { keyFor } from './separation.js';
15
+
16
+ /** Map of bridge id → { reach, only, total }, from the rows the views already have. */
17
+ export function exclusiveReach(degree2 = [], connections = []) {
18
+ const yours = new Set((connections || []).map(keyFor));
19
+ const bridgesOf = new Map(); // person → the bridges who reach them
20
+ for (const row of degree2 || []) {
21
+ const bridge = row?.source_connection_id;
22
+ if (bridge == null) continue;
23
+ const key = keyFor(row);
24
+ if (yours.has(key)) continue;
25
+ let set = bridgesOf.get(key);
26
+ if (!set) bridgesOf.set(key, (set = new Set()));
27
+ set.add(bridge);
28
+ }
29
+ const out = new Map();
30
+ for (const set of bridgesOf.values()) {
31
+ for (const bridge of set) {
32
+ const r = out.get(bridge) || { reach: 0, only: 0, total: 0 };
33
+ r.reach += 1 / set.size;
34
+ r.total += 1;
35
+ if (set.size === 1) r.only += 1;
36
+ out.set(bridge, r);
37
+ }
38
+ }
39
+ return out;
40
+ }
41
+
42
+ /**
43
+ * Which of your connections open the same doors: for each, the one whose
44
+ * circle overlaps theirs most, as the share of people either reaches that both
45
+ * do (Jaccard). "Maya and Tom open the same doors (62%)". Pairs are counted
46
+ * from the people reached two or more ways, so it stays quick.
47
+ *
48
+ * @returns Map of bridge id → { with, share, shared }
49
+ */
50
+ export function bridgeOverlap(degree2 = [], connections = []) {
51
+ const reach = exclusiveReach(degree2, connections);
52
+ const yours = new Set((connections || []).map(keyFor));
53
+ const bridgesOf = new Map();
54
+ for (const row of degree2 || []) {
55
+ const bridge = row?.source_connection_id;
56
+ if (bridge == null) continue;
57
+ const key = keyFor(row);
58
+ if (yours.has(key)) continue;
59
+ let set = bridgesOf.get(key);
60
+ if (!set) bridgesOf.set(key, (set = new Set()));
61
+ set.add(bridge);
62
+ }
63
+ const shared = new Map(); // "a|b" → people both reach
64
+ for (const set of bridgesOf.values()) {
65
+ if (set.size < 2) continue;
66
+ const list = [...set];
67
+ for (let i = 0; i < list.length; i++) {
68
+ for (let j = i + 1; j < list.length; j++) {
69
+ const k = list[i] < list[j] ? `${list[i]}|${list[j]}` : `${list[j]}|${list[i]}`;
70
+ shared.set(k, (shared.get(k) || 0) + 1);
71
+ }
72
+ }
73
+ }
74
+ const best = new Map();
75
+ for (const [k, both] of shared) {
76
+ const [a, b] = k.split('|');
77
+ const union = reach.get(a).total + reach.get(b).total - both;
78
+ const share = union ? both / union : 0;
79
+ for (const [me, other] of [[a, b], [b, a]]) {
80
+ const cur = best.get(me);
81
+ if (!cur || share > cur.share) best.set(me, { with: other, share, shared: both });
82
+ }
83
+ }
84
+ return best;
85
+ }
86
+
87
+ /** Of everyone you reach through a circle, the share you reach two or more ways. */
88
+ export function redundancy(degree2 = [], connections = []) {
89
+ const reach = exclusiveReach(degree2, connections);
90
+ let people = 0;
91
+ let only = 0;
92
+ for (const r of reach.values()) { people += r.reach; only += r.only; }
93
+ return people ? 1 - only / people : 0;
94
+ }
@@ -137,3 +137,92 @@ export function dotRadius(spacing, tier) {
137
137
  const scale = tier === 'S' ? 1 : tier === 'A' ? 0.86 : tier === 'B' ? 0.72 : 0.6;
138
138
  return Math.max(1.4, Math.min(7, spacing * 0.42 * scale));
139
139
  }
140
+
141
+ /**
142
+ * Where a bridge's circle is previewed on hover: a band beyond every ring of
143
+ * bridges. With many bridges the overview grows extra rings, past where the
144
+ * preview used to start, and it drew over them. `clear` leaves room for a
145
+ * hovered bridge's dot (20) and its name under it.
146
+ */
147
+ export function previewBand(maxR, bridgeRings = [], clear = 34) {
148
+ const edge = Math.max(0, ...bridgeRings.map((r) => r.radius));
149
+ const inner = Math.max(maxR * 0.66, edge ? edge + clear : 0);
150
+ return { inner, outer: Math.max(maxR * 0.8, inner + maxR * 0.14) };
151
+ }
152
+
153
+ const TIER_BANDS = ['S', 'A', 'B', 'C', 'D'];
154
+
155
+ /**
156
+ * An opened circle's people in tier bands round the person: S nearest, then A,
157
+ * B, C and D, with a gap between bands like Network Circle's tier orbits, so a
158
+ * big circle reads as layers instead of one clump. Points come back in the
159
+ * order of `rows`, each with `slot`: the angle its ring gives each dot. The
160
+ * spacing closes up, down to `minSpacing`, until every band fits inside
161
+ * `outer`; past that the bands carry on outwards and zooming out shows them.
162
+ *
163
+ * @returns {{ spacing: number, edge: number, bands: { tier: string, inner: number, outer: number }[],
164
+ * points: { x: number, y: number, angle: number, slot: number }[] }}
165
+ */
166
+ export function tierBandLayout(rows, { inner, outer, spacing, minSpacing = spacing / 3, gap = spacing }) {
167
+ const groups = TIER_BANDS.map((tier) => ({ tier, idx: [] }));
168
+ rows.forEach((row, i) => {
169
+ const t = TIER_BANDS.indexOf(row?.tier);
170
+ groups[t < 0 ? 4 : t].idx.push(i); // no tier: with D
171
+ });
172
+ const used = groups.filter((g) => g.idx.length);
173
+ const place = (s) => {
174
+ const points = new Array(rows.length);
175
+ const bands = [];
176
+ let r = inner;
177
+ for (const g of used) {
178
+ const l = ringLayout(g.idx.length, { inner: r, outer: Infinity, spacing: s, minSpacing: s });
179
+ g.idx.forEach((rowIndex, j) => {
180
+ const p = l.points[j];
181
+ points[rowIndex] = { x: p.x, y: p.y, angle: p.angle, slot: TAU / l.rings[p.ring].count };
182
+ });
183
+ const edge = Math.max(r, ...l.rings.map((x) => x.radius));
184
+ bands.push({ tier: g.tier, inner: r, outer: edge });
185
+ r = edge + gap * (s / spacing);
186
+ }
187
+ return { spacing: s, points, bands, edge: bands.length ? bands[bands.length - 1].outer : inner };
188
+ };
189
+ let best = place(spacing);
190
+ if (best.edge <= outer) return best;
191
+ let lo = minSpacing;
192
+ let hi = spacing;
193
+ best = place(lo);
194
+ if (best.edge > outer) return best;
195
+ for (let i = 0; i < 20; i++) {
196
+ const mid = (lo + hi) / 2;
197
+ const tryMid = place(mid);
198
+ if (tryMid.edge <= outer) { lo = mid; best = tryMid; } else hi = mid;
199
+ }
200
+ return best;
201
+ }
202
+
203
+ /**
204
+ * The people behind each person in an opened circle: their own circle, drawn
205
+ * as a fan of small dots beyond `from`, centred on that person's angle and no
206
+ * wider than their slot (at most a sixth of the circle), so each fan sits
207
+ * behind its own person. At most `cap` dots a fan and about `total` in all, so
208
+ * a big network stays quick; `more` is how many weren't drawn.
209
+ *
210
+ * @param {{ angle: number, slot: number, count: number }[]} anchors
211
+ * @returns {({ points: { x: number, y: number }[], more: number } | null)[]}
212
+ */
213
+ export function outerFans(anchors, { from, spacing = 7, cap = 40, total = 1200 } = {}) {
214
+ const fans = anchors.filter((a) => a?.count > 0).length;
215
+ const each = Math.max(6, Math.min(cap, Math.floor(total / Math.max(1, fans))));
216
+ return anchors.map((a) => {
217
+ if (!(a?.count > 0)) return null;
218
+ const n = Math.min(a.count, each);
219
+ // Only as wide as the dots need at `spacing`, never wider than their slot:
220
+ // more rows once it's full, so a small fan stays a tight cluster.
221
+ const room = Math.min(a.slot || TAU, TAU / 6);
222
+ const width = Math.min(room, Math.max((spacing * 3) / from, (n * spacing) / from));
223
+ const l = ringLayout(n, {
224
+ inner: from, outer: Infinity, spacing, minSpacing: spacing, start: a.angle - width / 2, sweep: width,
225
+ });
226
+ return { points: l.points.map(({ x, y }) => ({ x, y })), more: a.count - n };
227
+ });
228
+ }
@@ -137,17 +137,34 @@ export function circleLayout(rings = [], size = 240) {
137
137
  for (let k = 0; k < R; k++) {
138
138
  const radius = radiusOf(k);
139
139
  const people = rings[k].people;
140
- const dotR = clamp((2 * Math.PI * radius) / Math.max(1, people.length) * 0.32, 0.8, size / 60);
140
+ // More than their slice holds without the dots touching: rows, neighbours
141
+ // alternating between them, so a big circle never draws as a solid line.
142
+ // Each keeps its angle behind who they hang off. Rows go out towards the
143
+ // next ring, or for the outermost, in towards the one before (less than
144
+ // half way either side, so two rings' rows never meet).
145
+ const gap = Math.max(2.4, size / 100);
146
+ const inward = k === R - 1 && R > 1;
147
+ const band = (inward ? radius - radiusOf(k - 1) : (k + 1 < R ? radiusOf(k + 1) : half * 0.97) - radius) * 0.45;
148
+ const maxRows = Math.max(1, Math.floor(band / gap) + 1);
149
+ const rowGap = maxRows > 1 ? (inward ? -1 : 1) * Math.min(gap * 1.2, band / (maxRows - 1)) : 0;
150
+ const rowsFor = (count, arc) => clamp(Math.ceil((count * gap) / Math.max(1e-9, radius * arc)), 1, maxRows);
151
+ let rows = 1;
152
+ let room = Infinity; // the least room any one dot gets along its row
141
153
  const items = [];
142
154
  for (const [parentId, group] of byParent[k]) {
143
155
  const [a0, a1] = k === 0 ? [START, START + 2 * Math.PI] : span.get(parentId) || [START, START + 2 * Math.PI];
144
156
  const total = group.reduce((s, p) => s + weight.get(p), 0);
157
+ const groupRows = rowsFor(group.length, a1 - a0);
158
+ rows = Math.max(rows, groupRows);
159
+ room = Math.min(room, (radius * (a1 - a0) * groupRows) / Math.max(1, group.length));
160
+ let n = 0;
145
161
  let a = a0;
146
162
  for (const p of group) {
147
163
  const w = ((a1 - a0) * weight.get(p)) / total;
148
164
  const angle = a + w / 2;
149
- const x = half + radius * Math.cos(angle);
150
- const y = half + radius * Math.sin(angle);
165
+ const r = radius + (n++ % groupRows) * rowGap;
166
+ const x = half + r * Math.cos(angle);
167
+ const y = half + r * Math.sin(angle);
151
168
  items.push({ ...p, angle, x, y });
152
169
  if (p.state === 'connected') span.set(p.row.id, [a, a + w]);
153
170
  a += w;
@@ -163,7 +180,9 @@ export function circleLayout(rings = [], size = 240) {
163
180
  spokes.push({ x1: it.x, y1: it.y, x2: half + nextR * Math.cos(mid), y2: half + nextR * Math.sin(mid) });
164
181
  }
165
182
  }
166
- out.push({ degree: rings[k].degree, radius, dotR, items });
183
+ // Sized by the most crowded slice, and never wider than the gap between rows.
184
+ const dotR = clamp(Math.min(room * 0.32, rows > 1 ? Math.abs(rowGap) * 0.42 : Infinity), 0.8, size / 60);
185
+ out.push({ degree: rings[k].degree, radius, dotR, rows, items });
167
186
  }
168
187
  return { size, center: { x: half, y: half }, rings: out, spokes };
169
188
  }
@@ -0,0 +1,30 @@
1
+ // Everyone the app knows, by degree, for Network Circle's degree filter.
2
+ //
3
+ // 1st: your connections. 2nd: anyone in a scanned circle, including the people
4
+ // behind someone you added, since you're connected to them now. 3rd: anyone a
5
+ // company scan found who is neither. Each person counts once, at the nearest
6
+ // degree they're found (by profile, lib/separation.js keyFor), whichever
7
+ // circle or scan found them first.
8
+
9
+ import { keyFor } from './separation.js';
10
+
11
+ export function peopleByDegree(degree1 = [], degree2 = [], degree3 = []) {
12
+ const seen = new Set();
13
+ const take = (rows, degree) => {
14
+ const out = [];
15
+ for (const row of rows || []) {
16
+ const key = keyFor(row);
17
+ if (seen.has(key)) continue;
18
+ seen.add(key);
19
+ out.push(row.degree === degree ? row : { ...row, degree });
20
+ }
21
+ return out;
22
+ };
23
+ return { 1: take(degree1, 1), 2: take(degree2, 2), 3: take(degree3, 3) };
24
+ }
25
+
26
+ export function tierCountsOf(rows = []) {
27
+ const counts = {};
28
+ for (const row of rows) counts[row.tier] = (counts[row.tier] || 0) + 1;
29
+ return counts;
30
+ }
@@ -31,12 +31,12 @@ export function uniqueByProfile(records) {
31
31
  */
32
32
  export function splitAlreadyConnected(records, firstDegreeUrls) {
33
33
  const keep = [];
34
- let alreadyConnected = 0;
34
+ const connected = []; // their profile URLs: ties between your connections (lib/ties.js)
35
35
  for (const r of records) {
36
- if (firstDegreeUrls.has(r.profile_url)) alreadyConnected += 1;
36
+ if (firstDegreeUrls.has(r.profile_url)) connected.push(r.profile_url);
37
37
  else keep.push(r);
38
38
  }
39
- return { keep, alreadyConnected };
39
+ return { keep, alreadyConnected: connected.length, connected };
40
40
  }
41
41
 
42
42
  const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july',
@@ -5,8 +5,9 @@
5
5
  // changes here — the budget, and lifting a cooldown.
6
6
  //
7
7
  // The budget is searches (a day and a month) and profile views (a day). Profile
8
- // views are what got an account restricted (TRAPS §16), so their cap has no
9
- // "no limit", and the scanner also keeps any two a minute apart.
8
+ // views got an account restricted (TRAPS §16), so their cap has no "no limit",
9
+ // and the scanner also keeps any two a minute apart. Searches did too, at 373 in
10
+ // 24 hours: the page asks before a risky search budget (lib/search-risk.js).
10
11
  //
11
12
  // They belong to the LinkedIn account, not to a profile in the app. TRAPS §35.
12
13
 
@@ -58,14 +59,18 @@ export function nextMonthStartPacific(ms = Date.now()) {
58
59
  return monthStartPacific(start + 40 * DAY * 1000);
59
60
  }
60
61
 
62
+ const profileChoice = (n) => PROFILE_CHOICES.filter((c) => c <= n).pop() ?? PROFILE_CHOICES[0];
63
+
61
64
  export function readLimits(dir) {
62
65
  const raw = readJson(path.join(dir, 'scan-limits.json')) || {};
63
66
  const num = (v, d) => (Number.isInteger(v) && v >= 0 ? v : d);
64
67
  return {
65
68
  daily: num(raw.daily, DEFAULT_LIMITS.daily),
66
69
  monthly: num(raw.monthly, DEFAULT_LIMITS.monthly),
67
- // 0 is "no limit" for searches; for profile views the scanner reads it as the default.
68
- profiles: num(raw.profiles, 0) || DEFAULT_LIMITS.profiles,
70
+ // 0 is "no limit" for searches; for profile views the scanner reads it as the
71
+ // default. A number typed in by hand counts as the largest choice under it,
72
+ // as the scanner reads it (scrape.py search_limits).
73
+ profiles: profileChoice(num(raw.profiles, 0) || DEFAULT_LIMITS.profiles),
69
74
  };
70
75
  }
71
76
 
@@ -0,0 +1,38 @@
1
+ // How much searching is asking for trouble, for the Scan page's budget picker.
2
+ // No Node imports, so the page and the tests share it (lib/linkedin-limits.js
3
+ // reads files and can't load in the browser).
4
+ //
5
+ // The number behind it (TRAPS §16): on 2026-09-28 a real account was restricted
6
+ // after 373 searches in 24 hours, run back to back, with the daily budget raised
7
+ // to 500 and no monthly cap. It opened 7 profiles that day, so searches alone
8
+ // were enough.
9
+
10
+ export const RISKY_DAILY = 100; // above this, the picker asks first
11
+ export const RESTRICTED_AT = 373; // searches in 24 hours when that account was restricted
12
+ export const SAFE_LIMITS = { daily: 50, monthly: 250 };
13
+
14
+ export const riskyDaily = (n) => Number(n) > RISKY_DAILY;
15
+ export const riskyLimits = ({ daily, monthly } = {}) => riskyDaily(daily) || monthly === 0;
16
+
17
+ /** What the picker asks before a change that raises the risk; null when there's nothing to ask. */
18
+ export function limitQuestion(from = {}, to = {}) {
19
+ if (riskyDaily(to.daily) && Number(to.daily) > Number(from.daily)) {
20
+ return `${to.daily} searches a day is more than LinkedIn has put up with. A real account was `
21
+ + `restricted after ${RESTRICTED_AT} searches in 24 hours, run back to back. The default is `
22
+ + `${SAFE_LIMITS.daily} a day.\n\nSet ${to.daily} a day anyway?`;
23
+ }
24
+ if (to.monthly === 0 && from.monthly !== 0) {
25
+ return 'With no monthly cap, only the daily budget stops a scan. A real account was restricted '
26
+ + `after ${RESTRICTED_AT} searches in 24 hours with no monthly cap set.\n\nTurn the monthly cap off anyway?`;
27
+ }
28
+ return null;
29
+ }
30
+
31
+ /** The note under the picker while the budget is risky; null when it isn't. */
32
+ export function limitNote({ daily, monthly } = {}) {
33
+ if (!riskyLimits({ daily, monthly })) return null;
34
+ const parts = [`A real account was restricted after ${RESTRICTED_AT} searches in 24 hours.`];
35
+ if (riskyDaily(daily)) parts.push(`${daily} a day can use up a free account's month in a day or two.`);
36
+ if (monthly === 0) parts.push('With no monthly cap, nothing but the daily budget stops a long run.');
37
+ return parts.join(' ');
38
+ }
@@ -0,0 +1,23 @@
1
+ // Ties between your own connections: two people you know who know each other.
2
+ //
3
+ // A circle scan reads everyone in someone's list, your own connections among
4
+ // them, and the app sets those aside rather than saving them as 2nd degree
5
+ // (lib/ingest.js splitAlreadyConnected). Each one is a tie between that
6
+ // person and the one whose circle it is. Kept in connection_ties (db/schema.js),
7
+ // for clustering and communities later; nothing reads them yet.
8
+
9
+ /** The pairs to keep: the circle's person with each of your connections in it, each pair once, smaller URL first. */
10
+ export function tiePairs(ownerUrl, urls = []) {
11
+ const seen = new Set();
12
+ const out = [];
13
+ if (!ownerUrl) return out;
14
+ for (const url of urls) {
15
+ if (!url || url === ownerUrl) continue;
16
+ const [a, b] = url < ownerUrl ? [url, ownerUrl] : [ownerUrl, url];
17
+ const key = `${a} ${b}`;
18
+ if (seen.has(key)) continue;
19
+ seen.add(key);
20
+ out.push({ a_url: a, b_url: b });
21
+ }
22
+ return out;
23
+ }
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "six-degrees",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "six-degrees",
9
- "version": "0.4.0",
9
+ "version": "0.4.1",
10
10
  "license": "MIT",
11
11
  "os": [
12
12
  "darwin",
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "six-degrees",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "Map your professional network as a galaxy — leverage tiers, bridges into new circles, and the shortest path to anyone. Runs locally on your own data.",
5
5
  "license": "MIT",
6
6
  "author": "Blake Burford",
@@ -1504,16 +1504,29 @@ def take_profile_view(now=None):
1504
1504
  with _Locked("linkedin-activity"):
1505
1505
  now = now if now is not None else time.time()
1506
1506
  data = _read_activity()
1507
- views = [t for t in data["profiles"] if t > now - DAY_SECONDS]
1508
- if len(views) >= search_limits()["profiles"]:
1509
- return None
1510
- # A time ahead of this clock (another computer's, imported) is left
1511
- # out of the gap: waiting for it could never end.
1512
- last = max((t for t in views if t <= now), default=None)
1513
- if last is not None and now - last < PROFILE_GAP:
1514
- return int(last + PROFILE_GAP - now) + 1
1515
- _charge(data, "profiles", 1, now)
1516
- return 0
1507
+ wait = _profile_wait(data, now)
1508
+ if wait == 0:
1509
+ _charge(data, "profiles", 1, now)
1510
+ return wait
1511
+
1512
+
1513
+ def profile_wait(now=None):
1514
+ """take_profile_view's answer, writing nothing down: 0, seconds to wait, or None."""
1515
+ with _Locked("linkedin-activity"):
1516
+ now = now if now is not None else time.time()
1517
+ return _profile_wait(_read_activity(), now)
1518
+
1519
+
1520
+ def _profile_wait(data, now):
1521
+ views = [t for t in data["profiles"] if t > now - DAY_SECONDS]
1522
+ if len(views) >= search_limits()["profiles"]:
1523
+ return None
1524
+ # A time ahead of this clock (another computer's, imported) is left
1525
+ # out of the gap: waiting for it could never end.
1526
+ last = max((t for t in views if t <= now), default=None)
1527
+ if last is not None and now - last < PROFILE_GAP:
1528
+ return int(last + PROFILE_GAP - now) + 1
1529
+ return 0
1517
1530
 
1518
1531
 
1519
1532
  def search_limits():
@@ -1529,8 +1542,12 @@ def search_limits():
1529
1542
  return default
1530
1543
  return {"daily": num(data.get("daily"), DEFAULT_DAILY_SEARCHES),
1531
1544
  "monthly": num(data.get("monthly"), DEFAULT_MONTHLY_SEARCHES),
1532
- # 0 is no cap for searches, never for profile views.
1533
- "profiles": num(data.get("profiles"), DEFAULT_DAILY_PROFILES) or DEFAULT_DAILY_PROFILES}
1545
+ # 0 is no cap for searches, never for profile views. A number typed
1546
+ # in by hand counts as the largest choice under it: never past 100,
1547
+ # and the Scan page shows the same one.
1548
+ "profiles": max((c for c in (10, 25, 50, 100)
1549
+ if c <= (num(data.get("profiles"), DEFAULT_DAILY_PROFILES) or DEFAULT_DAILY_PROFILES)),
1550
+ default=10)}
1534
1551
 
1535
1552
 
1536
1553
  def linkedin_usage(now=None):
@@ -3475,6 +3492,17 @@ def rescrape_bridge(bridge_name, headless=False, max_pages=LINKEDIN_MAX_PAGES):
3475
3492
  raise BudgetReached(0, searches_left()[1])
3476
3493
  if profiles_left() <= 0: # a fresh read opens their profile
3477
3494
  raise BudgetReached(0, "profiles")
3495
+ # The minute between profile views is waited out before anything is
3496
+ # deleted: a Stop during it would otherwise leave the circle deleted and unread.
3497
+ wait = profile_wait()
3498
+ if wait is None:
3499
+ raise BudgetReached(0, "profiles")
3500
+ if wait:
3501
+ print(f" Waiting {wait}s before re-mapping: at least {PROFILE_GAP}s pass between any "
3502
+ f"two profile views.", flush=True)
3503
+ if not interruptible_sleep(wait, on_tick=lambda left: print(f" {left}s to go", flush=True), step=10):
3504
+ print(" Stopped before anything was deleted.")
3505
+ return
3478
3506
 
3479
3507
  print(f"Deleting old cluster data...")
3480
3508
  bridge_id = delete_bridge_cluster(bridge_name)