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.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +13 -13
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +18 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +18 -17
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +14 -13
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/admin-delete/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/admin-update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bridges/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/bulk-import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/legacy/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/company-scores/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/connections/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/export/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/import/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/restart/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/reveal/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/data/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/delete-cluster/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/ingest/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/network/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/notifications/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/outreach/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/queue/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/scraper/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-preview/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/settings/sector-suggestions/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/setup-profile/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/unlock/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/update-images/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/api/users/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/avatars/[file]/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import/page.js +1 -1
- package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/import.html +1 -1
- package/.next/standalone/.next/server/app/import.rsc +22 -21
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +20 -19
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +20 -19
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch/page.js +1 -1
- package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/launch.html +2 -2
- package/.next/standalone/.next/server/app/launch.rsc +22 -21
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/page.js +1 -1
- package/.next/standalone/.next/server/app/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths/page.js +1 -1
- package/.next/standalone/.next/server/app/paths/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/paths/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/paths.html +1 -1
- package/.next/standalone/.next/server/app/paths.rsc +22 -21
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/profile/page.js +1 -1
- package/.next/standalone/.next/server/app/profile/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/profile/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/profile.html +1 -1
- package/.next/standalone/.next/server/app/profile.rsc +22 -21
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/queue/page.js +1 -1
- package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/queue/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/queue.html +1 -1
- package/.next/standalone/.next/server/app/queue.rsc +22 -21
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/scores/page.js +1 -1
- package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/scores.html +1 -1
- package/.next/standalone/.next/server/app/scores.rsc +22 -21
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/settings/page.js +1 -1
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +22 -21
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/app/setup/page.js +1 -1
- package/.next/standalone/.next/server/app/setup/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/setup/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/setup.html +1 -1
- package/.next/standalone/.next/server/app/setup.rsc +22 -21
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +22 -21
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +16 -15
- package/.next/standalone/.next/server/chunks/_1_fynia._.js +6 -6
- package/.next/standalone/.next/server/chunks/_1itd1o_._.js +1 -1
- package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0d3vjsq._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0m4vk0b._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_15xuwkv._.js → _012mop3._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_1163x49._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1av11ws._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1ig7em7._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_20w06i-._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +4 -4
- package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +1 -1
- package/.next/standalone/.next/server/middleware-build-manifest.js +3 -3
- package/.next/standalone/.next/server/middleware-manifest.json +5 -5
- package/.next/standalone/.next/server/pages/404.html +1 -1
- package/.next/standalone/.next/server/pages/500.html +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/server-reference-manifest.json +1 -1
- package/.next/standalone/.next/static/chunks/{3e8zg6ctvvb8p.js → 0_3ubivay59bq.js} +4 -4
- package/.next/standalone/.next/static/chunks/{2p6o90z0vgcwr.css → 0bo1dxn5gwzpk.css} +1 -1
- package/.next/standalone/.next/static/chunks/0f4ugd6v3ph0-.js +1 -0
- package/.next/standalone/.next/static/chunks/10ogurbcuf3nq.js +1 -0
- package/.next/standalone/.next/static/chunks/1489t7jrjojk_.js +1 -0
- package/.next/standalone/.next/static/chunks/{0ax_9zzboyv6d.js → 17-rhp8sg4m1b.js} +2 -2
- package/.next/standalone/.next/static/chunks/1zunq4tk-u3ng.js +1 -0
- package/.next/standalone/.next/static/chunks/1zwjy1mmpkdrg.js +6 -0
- package/.next/standalone/.next/static/chunks/2_wcobfqry2uj.js +1 -0
- package/.next/standalone/.next/static/chunks/2dwtoszeq91us.js +1 -0
- package/.next/standalone/.next/static/chunks/2pyo4j84prgp6.js +18 -0
- package/.next/standalone/.next/static/chunks/2wwmljnf7yn_5.js +1 -0
- package/.next/{static/chunks/2xjho0lroiwlq.js → standalone/.next/static/chunks/3kllnkb3hof1s.js} +1 -1
- package/.next/standalone/CHANGELOG.md +54 -0
- package/.next/standalone/app/api/scraper/route.js +27 -2
- package/.next/standalone/app/components/ChainView.js +17 -10
- package/.next/standalone/app/components/FilterPanel.js +37 -5
- package/.next/standalone/app/components/ForceGraph.js +106 -13
- package/.next/standalone/app/components/OrbitGraph.js +105 -30
- package/.next/standalone/app/components/ScanStatusBar.js +82 -0
- package/.next/standalone/app/globals.css +4 -0
- package/.next/standalone/app/layout.js +3 -0
- package/.next/standalone/app/page.js +8 -4
- package/.next/standalone/app/setup/page.js +13 -1
- package/.next/standalone/lib/dot-rings.js +21 -0
- package/.next/standalone/lib/reach.js +22 -1
- package/.next/standalone/lib/scraper-client.js +1 -0
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/scripts/scrape.py +229 -5
- package/.next/static/chunks/{3e8zg6ctvvb8p.js → 0_3ubivay59bq.js} +4 -4
- package/.next/static/chunks/{2p6o90z0vgcwr.css → 0bo1dxn5gwzpk.css} +1 -1
- package/.next/static/chunks/0f4ugd6v3ph0-.js +1 -0
- package/.next/static/chunks/10ogurbcuf3nq.js +1 -0
- package/.next/static/chunks/1489t7jrjojk_.js +1 -0
- package/.next/static/chunks/{0ax_9zzboyv6d.js → 17-rhp8sg4m1b.js} +2 -2
- package/.next/static/chunks/1zunq4tk-u3ng.js +1 -0
- package/.next/static/chunks/1zwjy1mmpkdrg.js +6 -0
- package/.next/static/chunks/2_wcobfqry2uj.js +1 -0
- package/.next/static/chunks/2dwtoszeq91us.js +1 -0
- package/.next/static/chunks/2pyo4j84prgp6.js +18 -0
- package/.next/static/chunks/2wwmljnf7yn_5.js +1 -0
- package/.next/{standalone/.next/static/chunks/2xjho0lroiwlq.js → static/chunks/3kllnkb3hof1s.js} +1 -1
- package/CHANGELOG.md +54 -0
- package/lib/dot-rings.js +21 -0
- package/lib/reach.js +22 -1
- package/lib/scraper-client.js +1 -0
- package/package.json +1 -1
- package/scripts/scrape.py +229 -5
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0h1_f_2._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__19ip1qq._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_06w_gl4._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_13kzrwv._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_1f8t_rp._.js +0 -23
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_client_components_server-async-storage_0t1dato.js +0 -3
- package/.next/standalone/.next/static/chunks/10784r7w7t5gw.js +0 -18
- package/.next/standalone/.next/static/chunks/18xu0fbgo_qz-.js +0 -1
- package/.next/standalone/.next/static/chunks/28bu26ausqzmn.js +0 -1
- package/.next/standalone/.next/static/chunks/2jz311xl1pp_8.js +0 -1
- package/.next/standalone/.next/static/chunks/2ot61qsv6_0eq.js +0 -1
- package/.next/standalone/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
- package/.next/standalone/.next/static/chunks/3ol1xux1-ofm4.js +0 -6
- package/.next/standalone/.next/static/chunks/3zs_6-nn0dapb.js +0 -1
- package/.next/static/chunks/10784r7w7t5gw.js +0 -18
- package/.next/static/chunks/18xu0fbgo_qz-.js +0 -1
- package/.next/static/chunks/28bu26ausqzmn.js +0 -1
- package/.next/static/chunks/2jz311xl1pp_8.js +0 -1
- package/.next/static/chunks/2ot61qsv6_0eq.js +0 -1
- package/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
- package/.next/static/chunks/3ol1xux1-ofm4.js +0 -6
- package/.next/static/chunks/3zs_6-nn0dapb.js +0 -1
- /package/.next/standalone/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_ssgManifest.js +0 -0
- /package/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_buildManifest.js +0 -0
- /package/.next/static/{LlDgA9g8ToBkDLlo9Qtov → 814ehZBeFO2rUKle7mzX-}/_clientMiddlewareManifest.js +0 -0
- /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
|
|
223
|
-
// connection whose circle
|
|
224
|
-
//
|
|
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 =
|
|
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 ?
|
|
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 :
|
|
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
|
-
|
|
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
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
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((
|
|
256
|
+
const weight = (d) => Math.sqrt(reachOf(d) + 4);
|
|
227
257
|
const total = ordered.reduce((sum, d) => sum + weight(d), 0) || 1;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
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 =
|
|
270
|
+
let r = rStart;
|
|
242
271
|
while (placed < people.length) {
|
|
243
|
-
const fit = Math.max(1, Math.floor((r * wedge) /
|
|
272
|
+
const fit = Math.max(1, Math.floor((r * wedge) / step));
|
|
244
273
|
const row = people.slice(placed, placed + fit);
|
|
245
|
-
row.forEach((
|
|
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
|
|
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 +=
|
|
296
|
+
r += step;
|
|
257
297
|
}
|
|
258
|
-
|
|
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)')
|
|
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
|
-
|
|
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
|
-
()
|
|
175
|
-
|
|
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’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
|
});
|