six-degrees 0.4.1 → 0.4.3

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 (212) 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 +13 -13
  4. package/.next/standalone/.next/server/app/_global-error.html +1 -1
  5. package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
  6. package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
  7. package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  8. package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  9. package/.next/standalone/.next/server/app/_not-found/page.js +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/page_client-reference-manifest.js +1 -1
  12. package/.next/standalone/.next/server/app/_not-found.html +1 -1
  13. package/.next/standalone/.next/server/app/_not-found.rsc +18 -17
  14. package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +18 -17
  15. package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -13
  16. package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
  17. package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
  18. package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
  19. package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
  20. package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
  21. package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
  22. package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
  23. package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
  24. package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
  25. package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
  26. package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
  27. package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
  28. package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
  29. package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +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/import/page.js +1 -1
  46. package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
  47. package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
  48. package/.next/standalone/.next/server/app/import.html +1 -1
  49. package/.next/standalone/.next/server/app/import.rsc +22 -21
  50. package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +22 -21
  51. package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
  52. package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +16 -15
  53. package/.next/standalone/.next/server/app/index.html +1 -1
  54. package/.next/standalone/.next/server/app/index.rsc +20 -19
  55. package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +16 -15
  56. package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +20 -19
  57. package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
  58. package/.next/standalone/.next/server/app/launch/page.js +1 -1
  59. package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
  60. package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
  61. package/.next/standalone/.next/server/app/launch.html +2 -2
  62. package/.next/standalone/.next/server/app/launch.rsc +22 -21
  63. package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +22 -21
  64. package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
  65. package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +16 -15
  66. package/.next/standalone/.next/server/app/page.js +1 -1
  67. package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
  68. package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
  69. package/.next/standalone/.next/server/app/paths/page.js +1 -1
  70. package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
  71. package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
  72. package/.next/standalone/.next/server/app/paths.html +1 -1
  73. package/.next/standalone/.next/server/app/paths.rsc +22 -21
  74. package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +22 -21
  75. package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
  76. package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +16 -15
  77. package/.next/standalone/.next/server/app/profile/page.js +1 -1
  78. package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
  79. package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
  80. package/.next/standalone/.next/server/app/profile.html +1 -1
  81. package/.next/standalone/.next/server/app/profile.rsc +22 -21
  82. package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +22 -21
  83. package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
  84. package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +16 -15
  85. package/.next/standalone/.next/server/app/queue/page.js +1 -1
  86. package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
  87. package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
  88. package/.next/standalone/.next/server/app/queue.html +1 -1
  89. package/.next/standalone/.next/server/app/queue.rsc +22 -21
  90. package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +22 -21
  91. package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
  92. package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +16 -15
  93. package/.next/standalone/.next/server/app/scores/page.js +1 -1
  94. package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
  95. package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
  96. package/.next/standalone/.next/server/app/scores.html +1 -1
  97. package/.next/standalone/.next/server/app/scores.rsc +22 -21
  98. package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +22 -21
  99. package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +2 -2
  100. package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +16 -15
  101. package/.next/standalone/.next/server/app/settings/page.js +1 -1
  102. package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
  103. package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
  104. package/.next/standalone/.next/server/app/settings.html +1 -1
  105. package/.next/standalone/.next/server/app/settings.rsc +22 -21
  106. package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +22 -21
  107. package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
  108. package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +16 -15
  109. package/.next/standalone/.next/server/app/setup/page.js +1 -1
  110. package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
  111. package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
  112. package/.next/standalone/.next/server/app/setup.html +1 -1
  113. package/.next/standalone/.next/server/app/setup.rsc +22 -21
  114. package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +22 -21
  115. package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
  116. package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +16 -15
  117. package/.next/standalone/.next/server/chunks/_1_fynia._.js +6 -6
  118. package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
  119. package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +1 -1
  120. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0d3vjsq._.js +3 -0
  121. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m4vk0b._.js +3 -0
  122. package/.next/standalone/.next/server/chunks/ssr/{_15xuwkv._.js → _012mop3._.js} +2 -2
  123. package/.next/standalone/.next/server/chunks/ssr/_1163x49._.js +3 -0
  124. package/.next/standalone/.next/server/chunks/ssr/_1av11ws._.js +3 -0
  125. package/.next/standalone/.next/server/chunks/ssr/_1ig7em7._.js +3 -0
  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_setup_page_1-282zx.js +1 -1
  129. package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
  130. package/.next/standalone/.next/server/middleware-manifest.json +5 -5
  131. package/.next/standalone/.next/server/pages/404.html +1 -1
  132. package/.next/standalone/.next/server/pages/500.html +1 -1
  133. package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
  134. package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
  135. package/.next/standalone/.next/static/chunks/{3e8zg6ctvvb8p.js → 0_3ubivay59bq.js} +4 -4
  136. package/.next/standalone/.next/static/chunks/{2p6o90z0vgcwr.css → 0bo1dxn5gwzpk.css} +1 -1
  137. package/.next/standalone/.next/static/chunks/0f4ugd6v3ph0-.js +1 -0
  138. package/.next/standalone/.next/static/chunks/10ogurbcuf3nq.js +1 -0
  139. package/.next/standalone/.next/static/chunks/1489t7jrjojk_.js +1 -0
  140. package/.next/standalone/.next/static/chunks/{0ax_9zzboyv6d.js → 17-rhp8sg4m1b.js} +2 -2
  141. package/.next/standalone/.next/static/chunks/1zunq4tk-u3ng.js +1 -0
  142. package/.next/standalone/.next/static/chunks/1zwjy1mmpkdrg.js +6 -0
  143. package/.next/standalone/.next/static/chunks/2_wcobfqry2uj.js +1 -0
  144. package/.next/standalone/.next/static/chunks/2dwtoszeq91us.js +1 -0
  145. package/.next/standalone/.next/static/chunks/2pyo4j84prgp6.js +18 -0
  146. package/.next/standalone/.next/static/chunks/2wwmljnf7yn_5.js +1 -0
  147. package/.next/{static/chunks/2xjho0lroiwlq.js → standalone/.next/static/chunks/3kllnkb3hof1s.js} +1 -1
  148. package/.next/standalone/CHANGELOG.md +54 -0
  149. package/.next/standalone/app/api/scraper/route.js +27 -2
  150. package/.next/standalone/app/components/ChainView.js +17 -10
  151. package/.next/standalone/app/components/FilterPanel.js +37 -5
  152. package/.next/standalone/app/components/ForceGraph.js +106 -13
  153. package/.next/standalone/app/components/OrbitGraph.js +105 -30
  154. package/.next/standalone/app/components/ScanStatusBar.js +82 -0
  155. package/.next/standalone/app/globals.css +4 -0
  156. package/.next/standalone/app/layout.js +3 -0
  157. package/.next/standalone/app/page.js +8 -4
  158. package/.next/standalone/app/setup/page.js +13 -1
  159. package/.next/standalone/lib/dot-rings.js +21 -0
  160. package/.next/standalone/lib/reach.js +22 -1
  161. package/.next/standalone/lib/scraper-client.js +1 -0
  162. package/.next/standalone/package-lock.json +2 -2
  163. package/.next/standalone/package.json +1 -1
  164. package/.next/standalone/scripts/scrape.py +229 -5
  165. package/.next/static/chunks/{3e8zg6ctvvb8p.js → 0_3ubivay59bq.js} +4 -4
  166. package/.next/static/chunks/{2p6o90z0vgcwr.css → 0bo1dxn5gwzpk.css} +1 -1
  167. package/.next/static/chunks/0f4ugd6v3ph0-.js +1 -0
  168. package/.next/static/chunks/10ogurbcuf3nq.js +1 -0
  169. package/.next/static/chunks/1489t7jrjojk_.js +1 -0
  170. package/.next/static/chunks/{0ax_9zzboyv6d.js → 17-rhp8sg4m1b.js} +2 -2
  171. package/.next/static/chunks/1zunq4tk-u3ng.js +1 -0
  172. package/.next/static/chunks/1zwjy1mmpkdrg.js +6 -0
  173. package/.next/static/chunks/2_wcobfqry2uj.js +1 -0
  174. package/.next/static/chunks/2dwtoszeq91us.js +1 -0
  175. package/.next/static/chunks/2pyo4j84prgp6.js +18 -0
  176. package/.next/static/chunks/2wwmljnf7yn_5.js +1 -0
  177. package/.next/{standalone/.next/static/chunks/2xjho0lroiwlq.js → static/chunks/3kllnkb3hof1s.js} +1 -1
  178. package/CHANGELOG.md +54 -0
  179. package/lib/dot-rings.js +21 -0
  180. package/lib/reach.js +22 -1
  181. package/lib/scraper-client.js +1 -0
  182. package/package.json +1 -1
  183. package/scripts/scrape.py +229 -5
  184. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h1_f_2._.js +0 -3
  185. package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19ip1qq._.js +0 -3
  186. package/.next/standalone/.next/server/chunks/ssr/_06w_gl4._.js +0 -3
  187. package/.next/standalone/.next/server/chunks/ssr/_13kzrwv._.js +0 -3
  188. package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +0 -3
  189. package/.next/standalone/.next/server/chunks/ssr/node_modules_1f8t_rp._.js +0 -23
  190. package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_client_components_server-async-storage_0t1dato.js +0 -3
  191. package/.next/standalone/.next/static/chunks/10784r7w7t5gw.js +0 -18
  192. package/.next/standalone/.next/static/chunks/18xu0fbgo_qz-.js +0 -1
  193. package/.next/standalone/.next/static/chunks/28bu26ausqzmn.js +0 -1
  194. package/.next/standalone/.next/static/chunks/2jz311xl1pp_8.js +0 -1
  195. package/.next/standalone/.next/static/chunks/2ot61qsv6_0eq.js +0 -1
  196. package/.next/standalone/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
  197. package/.next/standalone/.next/static/chunks/3ol1xux1-ofm4.js +0 -6
  198. package/.next/standalone/.next/static/chunks/3zs_6-nn0dapb.js +0 -1
  199. package/.next/static/chunks/10784r7w7t5gw.js +0 -18
  200. package/.next/static/chunks/18xu0fbgo_qz-.js +0 -1
  201. package/.next/static/chunks/28bu26ausqzmn.js +0 -1
  202. package/.next/static/chunks/2jz311xl1pp_8.js +0 -1
  203. package/.next/static/chunks/2ot61qsv6_0eq.js +0 -1
  204. package/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
  205. package/.next/static/chunks/3ol1xux1-ofm4.js +0 -6
  206. package/.next/static/chunks/3zs_6-nn0dapb.js +0 -1
  207. /package/.next/standalone/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_buildManifest.js +0 -0
  208. /package/.next/standalone/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_clientMiddlewareManifest.js +0 -0
  209. /package/.next/standalone/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_ssgManifest.js +0 -0
  210. /package/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_buildManifest.js +0 -0
  211. /package/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_clientMiddlewareManifest.js +0 -0
  212. /package/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_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,7 +216,7 @@ 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,
@@ -208,6 +224,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
208
224
 
209
225
  const nodes = [centerNode, ...connections.map(c => ({
210
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,18 +236,54 @@ 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
- // 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.
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.
225
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
+ };
226
265
  const links = connections.map(c => {
227
- const via = (c.degree || 1) > 1 && drawnD1.has(c.source_connection_id) ? c.source_connection_id : CENTER_ID;
266
+ const via = parentOf.get(c.id) ?? CENTER_ID;
228
267
  return { source: via, target: c.id, near: via !== CENTER_ID };
229
268
  });
230
269
 
231
270
  const tierRadius = (tier) => {
232
271
  switch (tier) { case 'S': return 150; case 'A': return 250; case 'B': return 350; case 'C': return 450; default: return 520; }
233
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
+ };
234
287
 
235
288
  const nodeRadius = (d) => {
236
289
  if (d.id === CENTER_ID) return 18;
@@ -249,7 +302,19 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
249
302
  // Once the layout settles they move to where each tier's dots actually sit
250
303
  // (the median distance out), which the other forces push past that radius.
251
304
  const guideRings = new Map();
252
- if (!isMobileGraph) {
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) {
253
318
  const guides = g.append('g').attr('class', 'tier-guides').style('pointer-events', 'none');
254
319
  for (const tier of ['S', 'A', 'B', 'C', 'D']) {
255
320
  const r = tierRadius(tier);
@@ -357,11 +422,11 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
357
422
  }
358
423
 
359
424
  const simulation = d3.forceSimulation(nodes)
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))
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))
361
426
  .force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
362
427
  .force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
363
428
  .force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
364
- .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));
365
430
  simulation.on('end.guides', () => {
366
431
  for (const [tier, { ring, label }] of guideRings) {
367
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);
@@ -380,7 +445,10 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
380
445
  .attr('stroke-opacity', 0.15).attr('stroke-width', 0.5);
381
446
 
382
447
  // Catalyst outer glow rings (rendered behind the nodes)
383
- 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);
384
452
  if (catalysts.length) {
385
453
  const defs = svg.select('defs').size() ? svg.select('defs') : svg.append('defs');
386
454
  if (!defs.select('#catalyst-glow-nm').size()) {
@@ -397,6 +465,30 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
397
465
  .attr('class', 'catalyst-ring');
398
466
  }
399
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
+
400
492
  const node = g.append('g').selectAll('circle').data(nodes).join('circle')
401
493
  .attr('r', nodeRadius)
402
494
  .attr('fill', d => d.id === CENTER_ID ? '#fff' : tierColors[d.tier] || '#666')
@@ -483,6 +575,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
483
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);
484
576
  node.attr('cx', d => d.x).attr('cy', d => d.y);
485
577
  catalystRings.attr('cx', d => d.x).attr('cy', d => d.y);
578
+ dotRings.attr('transform', d => `translate(${d.x},${d.y})`);
486
579
  labels.attr('x', d => d.x).attr('y', d => d.y);
487
580
  placeRing();
488
581
  });
@@ -213,49 +213,121 @@ 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
+ // Their people go behind this fan, centred on it (not on wherever the
299
+ // person they came through sits), side by side in the order those people
300
+ // sit, so no fan spills into the next one's space; a line runs back to
301
+ // each. Wider than the wedge, they close up to fit it.
302
+ let edge = r;
303
+ const next = subs.filter(({ d }) => depth < 6 && reachOf(d)).sort((x, y) => x.a - y.a);
304
+ if (next.length) {
305
+ const from = r + 24;
306
+ const gap = 10 / from;
307
+ const want = next.map(({ d }) => Math.max(0.12, (Math.min(reachOf(d), 60) * 8) / from));
308
+ const need = want.reduce((sum, w) => sum + w, 0) + gap * (next.length - 1);
309
+ const scale = need > wedge ? wedge / need : 1;
310
+ let at = angle - (need * scale) / 2;
311
+ next.forEach(({ d }, k) => {
312
+ const w = want[k] * scale;
313
+ edge = Math.max(edge, placeFan(d, at + w / 2, w, from, depth + 1));
314
+ at += w + gap * scale;
315
+ });
316
+ }
317
+ return edge;
318
+ };
319
+
320
+ let cursor = -Math.PI / 2;
321
+ ordered.forEach((b) => {
322
+ const share = (weight(b) / total) * Math.PI * 2;
323
+ const angle = cursor + share / 2;
324
+ cursor += share;
325
+ const wedge = Math.min(share * 0.88, 1.6);
326
+ b.x = b.fx = Math.cos(angle) * R;
327
+ b.y = b.fy = Math.sin(angle) * R;
328
+ b.hub = true;
329
+ b.r = 9 + Math.min(10, (clusters.get(b.id)?.length || 0) / 40);
330
+ sceneRadius = Math.max(sceneRadius, placeFan(b, angle, wedge, R + b.r + 16, 2) + 10);
259
331
  });
260
332
  }
261
333
  for (const [bridgeId, cluster] of degreesMode ? [] : clusters) {
@@ -324,7 +396,8 @@ const OrbitGraph = forwardRef(function OrbitGraph(
324
396
  }
325
397
 
326
398
  const link = linksLayer.selectAll('line').data(links).join('line')
327
- .attr('stroke', 'rgba(255,255,255,0.08)').attr('stroke-width', 1);
399
+ .attr('stroke', (l) => (l.faint ? 'rgba(0,255,136,0.12)' : 'rgba(255,255,255,0.08)'))
400
+ .attr('stroke-width', (l) => (l.faint ? 0.5 : 1));
328
401
 
329
402
  const node = nodesLayer.selectAll('g').data(renderNodes, (d) => d.id).join('g')
330
403
  .attr('cursor', 'pointer');
@@ -333,7 +406,7 @@ const OrbitGraph = forwardRef(function OrbitGraph(
333
406
  const g = select(this);
334
407
 
335
408
  if (d.kind === 'd2') {
336
- g.attr('opacity', degreesMode ? 0.85 : 0.55);
409
+ g.attr('opacity', degreesMode ? (d.depth > 2 ? 0.6 : 0.85) : 0.55);
337
410
  g.append('circle').attr('class', 'og-base').attr('r', d.r)
338
411
  .attr('fill', TIER_COLORS[d.tier] || TIER_COLORS.D);
339
412
  return; // selection ring is created on demand, see applySelection
@@ -347,8 +420,10 @@ const OrbitGraph = forwardRef(function OrbitGraph(
347
420
  // selecting promotes it — the tooltip carries the detail, so nothing is
348
421
  // hidden, it is just not all shouted at once.
349
422
  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);
423
+ g.attr('opacity', d.added ? 1 : 0.75);
424
+ // Someone you added through a circle: green-ringed, with their own fan behind them.
425
+ g.append('circle').attr('class', 'og-base').attr('r', d.r).attr('fill', color)
426
+ .attr('stroke', d.added ? '#00ff88' : 'none').attr('stroke-width', d.added ? 1.5 : 0);
352
427
  return; // selection ring is created on demand, see applySelection
353
428
  }
354
429
 
@@ -0,0 +1,82 @@
1
+ 'use client';
2
+
3
+ // The scan status bar: while a scan runs, every page shows what it's doing,
4
+ // for whom, how far it's got, and today's LinkedIn budget, with Stop. Stop
5
+ // saves what was read, and the Scan page's Resume carries on from that page.
6
+ // It sits just under the header's tabs (Network Circle, Degrees…), on every
7
+ // page, the Scan page too (Blake, 2026-09-29). It only reports: the
8
+ // pacing and the caps live in the scanner (scripts/scrape.py, lib/linkedin-limits.js).
9
+
10
+ import { useEffect, useState } from 'react';
11
+ import Link from 'next/link';
12
+ import { watchScanner, scannerNow, stopScrape } from '../../lib/scraper-client';
13
+
14
+ const WHAT = {
15
+ full: 'Scanning your network',
16
+ refresh: 'Checking for new connections',
17
+ bridge: 'Scanning a circle',
18
+ resume: 'Carrying on with a circle',
19
+ 'resume-all': 'Carrying on with circles',
20
+ 'auto-bridge': 'Auto-Bridge',
21
+ 'auto-bridge-retry': 'Auto-Bridge',
22
+ rescrape: 'Re-scanning a circle',
23
+ login: 'Signing in to LinkedIn',
24
+ setup: 'Setting up the scanner',
25
+ company: 'Scanning a company',
26
+ photos: 'Saving photos',
27
+ };
28
+
29
+ // Green while there's plenty left, amber past 60%, red past 90%.
30
+ const tone = (used, cap) => (!cap ? '#8b9a9a' : used / cap >= 0.9 ? '#ff6b6b' : used / cap >= 0.6 ? '#FFD700' : '#00ff88');
31
+
32
+ export default function ScanStatusBar() {
33
+ const [job, setJob] = useState(() => scannerNow());
34
+ const [stopping, setStopping] = useState(false);
35
+ useEffect(() => watchScanner(() => {
36
+ const now = scannerNow();
37
+ setJob(now);
38
+ if (!now.running) setStopping(false);
39
+ }), []);
40
+ if (!job?.running) return null;
41
+
42
+ const p = job.progress;
43
+ const step = p?.kind === 'batch' && p.total ? `${p.current || p.done || 0} of ${p.total}`
44
+ : p?.kind === 'walk' && p.total ? `${(p.done || 0).toLocaleString()} of ${p.total.toLocaleString()} read`
45
+ : p?.kind === 'saving' ? 'saving' : null;
46
+ const b = job.budget;
47
+ const last = [...(job.log || [])].reverse().find((l) => l && l.trim())?.trim();
48
+
49
+ return (
50
+ <div role="status" aria-live="polite" style={{
51
+ position: 'fixed', left: '50%', top: 'var(--scan-bar-top, 94px)', transform: 'translateX(-50%)', zIndex: 60,
52
+ maxWidth: 'calc(100vw - 32px)', display: 'flex', alignItems: 'center', gap: 12, flexWrap: 'nowrap', whiteSpace: 'nowrap',
53
+ padding: '8px 12px', borderRadius: 12, fontSize: 12, color: '#cfd8d8',
54
+ background: 'rgba(14,16,32,0.94)', border: '1px solid rgba(0,255,136,0.25)',
55
+ }}>
56
+ <span style={{ width: 8, height: 8, borderRadius: '50%', background: '#00ff88', flexShrink: 0 }} />
57
+ <b style={{ color: '#fff' }}>{WHAT[job.action] || 'Scanning'}</b>
58
+ {job.target?.name && <span>{job.target.name}</span>}
59
+ {step && <span style={{ color: '#8b9a9a' }}>{step}</span>}
60
+ {b && (
61
+ <span style={{ color: tone(b.searches, b.cap) }} title="Searches on your LinkedIn account in the last 24 hours, and your daily budget">
62
+ {b.searches}{b.cap ? ` of ${b.cap}` : ''} searches today
63
+ </span>
64
+ )}
65
+ {last && (
66
+ <span style={{ color: '#667', flex: '0 1 240px', minWidth: 0, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={last}>
67
+ {last}
68
+ </span>
69
+ )}
70
+ <Link href="/setup" style={{ color: '#3498DB', textDecoration: 'none' }}>Details</Link>
71
+ <button
72
+ onClick={async () => { setStopping(true); try { await stopScrape(); } catch { setStopping(false); } }}
73
+ disabled={stopping}
74
+ title="Stops after saving what's been read; Resume on the Scan page carries on from the same page"
75
+ style={{
76
+ padding: '4px 10px', borderRadius: 8, fontSize: 12, cursor: stopping ? 'default' : 'pointer',
77
+ background: 'rgba(255,107,107,0.12)', color: '#ff9b9b', border: '1px solid rgba(255,107,107,0.35)',
78
+ }}
79
+ >{stopping ? 'Stopping…' : 'Stop'}</button>
80
+ </div>
81
+ );
82
+ }
@@ -38,3 +38,7 @@ html:has([data-field-question]) {
38
38
  background: rgba(255, 255, 255, 0.15);
39
39
  border-radius: 3px;
40
40
  }
41
+
42
+ /* Where the scan status bar sits: just under the header (app/components/ScanStatusBar.js). */
43
+ :root { --scan-bar-top: 94px; }
44
+ @media (max-width: 767px) { :root { --scan-bar-top: 66px; } }
@@ -19,6 +19,7 @@ export const metadata = {
19
19
 
20
20
  import UserProvider from './components/UserProvider';
21
21
  import StaleServerBanner from './components/StaleServerBanner';
22
+ import ScanStatusBar from './components/ScanStatusBar';
22
23
 
23
24
  export default function RootLayout({ children }) {
24
25
  return (
@@ -30,6 +31,8 @@ export default function RootLayout({ children }) {
30
31
  is running. Every page, because the answer to "did my update
31
32
  work?" should not require finding the right screen. */}
32
33
  <StaleServerBanner />
34
+ {/* While a scan runs: what it's doing and today's budget, with Stop. */}
35
+ <ScanStatusBar />
33
36
  </UserProvider>
34
37
  </body>
35
38
  </html>
@@ -73,6 +73,8 @@ function HomeInner() {
73
73
  const [degree3, setDegree3] = useState([]);
74
74
  // Which degrees Network Circle draws: your connections alone until you pick more.
75
75
  const [degrees, setDegrees] = useState([1]);
76
+ // Tiers switched off in Network Circle's Filter panel (C and D make a lot of noise).
77
+ const [hiddenTiers, setHiddenTiers] = useState([]);
76
78
  const [selected, setSelected] = useState(null);
77
79
  const focusNodeRef = useRef(null);
78
80
  const [filter, setFilter] = useState('all');
@@ -170,10 +172,10 @@ function HomeInner() {
170
172
  () => (isDegreesMode ? connections : degrees.flatMap((d) => byDegree[d] || [])),
171
173
  [isDegreesMode, connections, degrees, byDegree],
172
174
  );
173
- const filtered = useMemo(
174
- () => (filter === 'all' ? networkRows : networkRows.filter(c => c.tier === filter)),
175
- [networkRows, filter],
176
- );
175
+ const filtered = useMemo(() => {
176
+ if (!isDegreesMode) return hiddenTiers.length ? networkRows.filter(c => !hiddenTiers.includes(c.tier)) : networkRows;
177
+ return filter === 'all' ? networkRows : networkRows.filter(c => c.tier === filter);
178
+ }, [isDegreesMode, networkRows, filter, hiddenTiers]);
177
179
  // Lookups built once per load. Every click re-renders this component, and the
178
180
  // three places below used to scan one list inside another — degree1.find per
179
181
  // 2nd-degree row, degree2.some per connection — about 16M comparisons, twice
@@ -438,6 +440,8 @@ function HomeInner() {
438
440
  tierCounts={isDegreesMode ? stats?.tiers || {} : tierCountsOf(networkRows)}
439
441
  degrees={degrees}
440
442
  onDegreesChange={setDegrees}
443
+ hiddenTiers={hiddenTiers}
444
+ onHiddenTiersChange={setHiddenTiers}
441
445
  degreeCounts={{ 1: byDegree[1].length, 2: byDegree[2].length, 3: byDegree[3].length }}
442
446
  bridgeTierCounts={bridgeTierCounts}
443
447
  />
@@ -65,6 +65,8 @@ function SetupInner() {
65
65
  const [pages, setPages] = useRemembered('six-degrees-bridge-pages-v2', 100);
66
66
  // Also finish people mapped before, from the page each one's read stopped at.
67
67
  const [finish, setFinish] = useRemembered('six-degrees-bridge-finish', true);
68
+ // Experimental Auto-Bridge: all-day pacing and LinkedIn's own data (scripts/scrape.py --experimental).
69
+ const [experimental, setExperimental] = useRemembered('six-degrees-experimental-auto', false);
68
70
  const [error, setError] = useState(null);
69
71
  const logRef = useRef(null);
70
72
  // What's saved, for the question about your field: undefined while it
@@ -440,6 +442,16 @@ function SetupInner() {
440
442
  <input type="checkbox" checked={finish} onChange={(e) => setFinish(e.target.checked)} disabled={running} />
441
443
  Also finish people already mapped, from the page each one stopped at
442
444
  </label>
445
+ <label style={{ display: 'flex', gap: 8, alignItems: 'flex-start', fontSize: 12.5, color: '#b8c4c4', cursor: running ? 'default' : 'pointer' }}>
446
+ <input type="checkbox" checked={experimental} disabled={running} style={{ marginTop: 3 }}
447
+ onChange={(e) => setExperimental(e.target.checked)} />
448
+ <span>
449
+ <b style={{ color: '#FFD700' }}>Experimental:</b> all-day pacing. Up to 10 pages in a sitting,
450
+ then a 45-minute rest; searches only from 9:00 to 19:00; at the daily budget it waits for it to
451
+ free up instead of stopping; and it saves every page. It also reads LinkedIn&rsquo;s own data beside
452
+ the page text, to fill gaps and measure how the two compare. It keeps running while the app is open.
453
+ </span>
454
+ </label>
443
455
  <div style={{ fontSize: 12, color: '#FFD700', lineHeight: 1.6 }}>
444
456
  Every page is a LinkedIn search, so it rests 20 seconds before each one and a
445
457
  minute after every 10, and a long list can take
@@ -449,7 +461,7 @@ function SetupInner() {
449
461
  what it read and stops, and carries on from that page next time. Keep batches small.
450
462
  </div>
451
463
  <div style={{ display: 'flex', gap: 10, flexWrap: 'wrap', alignItems: 'center' }}>
452
- <Btn onClick={() => run('auto-bridge', { maxBridges: batch, tiers: order === 'score' ? tiers : [], order, maxPages: pages, deeper: finish })} disabled={!canSearch || (order === 'score' && !tiers.length)} primary>
464
+ <Btn onClick={() => run('auto-bridge', { maxBridges: batch, tiers: order === 'score' ? tiers : [], order, maxPages: pages, deeper: finish, experimental })} disabled={!canSearch || (order === 'score' && !tiers.length)} primary>
453
465
  {running && s.action === 'auto-bridge' ? 'Mapping…' : 'Map 2nd degree'}
454
466
  </Btn>
455
467
  <select
@@ -0,0 +1,21 @@
1
+ // The thin ring of five bars round a person's dot: how much of their circle is
2
+ // scanned (lib/reach.js scanBars). Blake's pick, design C: thin bars, a
3
+ // catalyst's green outline on the dot itself, a small count badge.
4
+ //
5
+ // Plain path strings, so the Galaxy (d3) and Bridge Chains (React) draw the
6
+ // same shape. Bars run clockwise from the top.
7
+
8
+ const GAP = 0.32; // radians between bars
9
+
10
+ /** Five arcs of radius r around (0, 0): [{ d, i }] */
11
+ export function ringSegments(r) {
12
+ const span = (2 * Math.PI - 5 * GAP) / 5;
13
+ return Array.from({ length: 5 }, (_, i) => {
14
+ const a0 = -Math.PI / 2 + GAP / 2 + i * (span + GAP);
15
+ const a1 = a0 + span;
16
+ const p = (a) => `${(r * Math.cos(a)).toFixed(2)} ${(r * Math.sin(a)).toFixed(2)}`;
17
+ return { i, d: `M${p(a0)} A${r} ${r} 0 0 1 ${p(a1)}` };
18
+ });
19
+ }
20
+
21
+ export const RING = { filled: '#1abc9c', empty: 'rgba(255,255,255,0.16)', badge: '#7F77DD' };
@@ -45,6 +45,10 @@ export function reachIndex(connections = [], degree2 = [], notes = {}) {
45
45
  for (const url of notes?.read || []) {
46
46
  if (url) read.add(keyFor({ profile_url: url }));
47
47
  }
48
+ const lists = new Map();
49
+ for (const [url, list] of Object.entries(notes?.lists || {})) {
50
+ if (url && list) lists.set(keyFor({ profile_url: url }), list);
51
+ }
48
52
  const reachedFrom = new Map();
49
53
  for (const row of connections || []) {
50
54
  const from = row?.unlocked_from_bridge_id;
@@ -53,7 +57,7 @@ export function reachIndex(connections = [], degree2 = [], notes = {}) {
53
57
  if (!list) reachedFrom.set(from, (list = []));
54
58
  list.push(row);
55
59
  }
56
- return { scanned, read, hidden, reachedFrom };
60
+ return { scanned, read, hidden, reachedFrom, lists };
57
61
  }
58
62
 
59
63
  /**
@@ -111,3 +115,20 @@ export function circleScanCost(pages = 100) {
111
115
  minutes: searches >= 100 ? 55 : Math.max(5, Math.round(searches * 0.55)),
112
116
  };
113
117
  }
118
+
119
+ /**
120
+ * How much of someone's circle is scanned, as 0–5 bars for the ring round
121
+ * their dot; null for no ring (not scanned, or their list is hidden).
122
+ * A list read to the end is 5. Part-read, it's the pages read against
123
+ * LinkedIn's own count of the list (about 10 a page), 1 to 4; with no count
124
+ * (read before the scanner kept one), 2.
125
+ */
126
+ export function scanBars(person, reach) {
127
+ const state = circleState(person, reach);
128
+ const list = person?.profile_url ? reach.lists?.get(keyFor(person)) : null;
129
+ if (state === 'hidden') return null;
130
+ if (!list) return state === 'scanned' ? 2 : null;
131
+ if (!list.more) return 5;
132
+ if (!list.total) return 2;
133
+ return Math.max(1, Math.min(4, Math.round((5 * Math.min(1, (list.pages * 10) / list.total)))));
134
+ }
@@ -148,6 +148,7 @@ function take(s) {
148
148
  target: running ? s.target ?? null : starting ? now.target : null,
149
149
  startedAt: running ? s.startedAt ?? null : null,
150
150
  progress: running ? s.progress ?? null : null,
151
+ budget: running ? s.budget ?? null : null,
151
152
  log: running && Array.isArray(s.log) ? s.log : [],
152
153
  finished,
153
154
  });