six-degrees 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.next/standalone/.next/BUILD_ID +1 -1
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +14 -14
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +2 -2
- 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 +2 -2
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
- 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 +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/favicon.ico/route.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/favicon.ico.body +0 -0
- package/.next/standalone/.next/server/app/import/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/import.html +1 -1
- package/.next/standalone/.next/server/app/import.rsc +2 -2
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/launch.html +2 -2
- package/.next/standalone/.next/server/app/launch.rsc +2 -2
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +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.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 +3 -3
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +2 -2
- 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 +3 -3
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/scores.html +1 -1
- package/.next/standalone/.next/server/app/scores.rsc +2 -2
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +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 +3 -3
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +3 -3
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e0fei3._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__03ydxxh._.js → [root-of-the-server]__20tmem7._.js} +1 -1
- 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/_1iwkh8t._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1mxn_lb._.js +1 -1
- package/.next/standalone/.next/server/chunks/lib_db-client_15le7cw.js +21 -7
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00d8snt._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__00g61b8._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__08lf3gy._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0hg5gib._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ho-u0w._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__0ze473p._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1194jps._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1cnxxc9._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1ll97ry._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1m0255u._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1w9z0zv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_15xuwkv._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +1 -1
- 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_paths_page_1ol90m7.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +6 -2
- 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 → 049vavjsmqgmn.js} +3 -3
- package/.next/standalone/.next/static/chunks/0ljxa-wk_iu__.js +18 -0
- package/.next/standalone/.next/static/chunks/0mp9b72fm-jp6.js +6 -0
- package/.next/standalone/.next/static/chunks/1pre1cotntk6-.js +1 -0
- package/.next/standalone/.next/static/chunks/{18xu0fbgo_qz-.js → 22x2_r768-u5i.js} +1 -1
- package/.next/standalone/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
- package/.next/standalone/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
- package/.next/standalone/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
- package/.next/standalone/CHANGELOG.md +86 -0
- package/.next/standalone/app/api/admin-delete/route.js +3 -0
- package/.next/standalone/app/api/ingest/route.js +13 -0
- package/.next/standalone/app/api/scraper/route.js +10 -2
- package/.next/standalone/app/components/ChainView.js +76 -15
- package/.next/standalone/app/components/FilterPanel.js +37 -1
- package/.next/standalone/app/components/ForceGraph.js +144 -12
- package/.next/standalone/app/components/LinkedInLimits.js +20 -3
- package/.next/standalone/app/components/OrbitGraph.js +92 -30
- package/.next/standalone/app/components/Sidebar.js +37 -0
- package/.next/standalone/app/components/views.js +7 -5
- package/.next/standalone/app/favicon.ico +0 -0
- package/.next/standalone/app/page.js +24 -7
- package/.next/standalone/app/paths/page.js +8 -1
- package/.next/standalone/db/schema.js +14 -0
- package/.next/standalone/lib/brokerage.js +94 -0
- package/.next/standalone/lib/chain-layout.js +89 -0
- package/.next/standalone/lib/circle.js +23 -4
- package/.next/standalone/lib/degrees.js +30 -0
- package/.next/standalone/lib/dot-rings.js +21 -0
- package/.next/standalone/lib/ingest.js +3 -3
- package/.next/standalone/lib/linkedin-limits.js +9 -4
- package/.next/standalone/lib/reach.js +22 -1
- package/.next/standalone/lib/search-risk.js +38 -0
- package/.next/standalone/lib/ties.js +23 -0
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/scripts/scrape.py +50 -12
- package/.next/static/chunks/{3e8zg6ctvvb8p.js → 049vavjsmqgmn.js} +3 -3
- package/.next/static/chunks/0ljxa-wk_iu__.js +18 -0
- package/.next/static/chunks/0mp9b72fm-jp6.js +6 -0
- package/.next/static/chunks/1pre1cotntk6-.js +1 -0
- package/.next/static/chunks/{18xu0fbgo_qz-.js → 22x2_r768-u5i.js} +1 -1
- package/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
- package/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
- package/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
- package/CHANGELOG.md +86 -0
- package/db/schema.js +14 -0
- package/lib/brokerage.js +94 -0
- package/lib/chain-layout.js +89 -0
- package/lib/circle.js +23 -4
- package/lib/degrees.js +30 -0
- package/lib/dot-rings.js +21 -0
- package/lib/ingest.js +3 -3
- package/lib/linkedin-limits.js +9 -4
- package/lib/reach.js +22 -1
- package/lib/search-risk.js +38 -0
- package/lib/ties.js +23 -0
- package/package.json +1 -1
- package/scripts/scrape.py +50 -12
- package/.next/standalone/.next/static/chunks/0w76iraw7ij3k.js +0 -18
- package/.next/standalone/.next/static/chunks/135n87ydxjrra.js +0 -2
- package/.next/standalone/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
- package/.next/standalone/.next/static/chunks/3qh78g2aqhprd.js +0 -2
- package/.next/standalone/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
- package/.next/static/chunks/0w76iraw7ij3k.js +0 -18
- package/.next/static/chunks/135n87ydxjrra.js +0 -2
- package/.next/static/chunks/2pnbm1z6j7xab.js +0 -1
- package/.next/static/chunks/3qh78g2aqhprd.js +0 -2
- package/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
- /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_ssgManifest.js +0 -0
- /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_buildManifest.js +0 -0
- /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → wGVrXBkFLmEB4snfhtOcO}/_ssgManifest.js +0 -0
|
@@ -8,6 +8,7 @@ import useRequests from './components/useRequests';
|
|
|
8
8
|
import { requestCount } from '../lib/requests-client';
|
|
9
9
|
import { loadNetwork } from '../lib/network';
|
|
10
10
|
import { VIEWS, resolveView } from './components/views';
|
|
11
|
+
import { peopleByDegree, tierCountsOf } from '../lib/degrees';
|
|
11
12
|
import Sidebar from './components/Sidebar';
|
|
12
13
|
import FilterPanel from './components/FilterPanel';
|
|
13
14
|
import OnboardingGate from './components/OnboardingGate';
|
|
@@ -68,6 +69,10 @@ function HomeInner() {
|
|
|
68
69
|
const isMobile = useIsMobile();
|
|
69
70
|
const [degree1, setDegree1] = useState([]);
|
|
70
71
|
const [degree2, setDegree2] = useState([]);
|
|
72
|
+
// People found only by company scans: 3rd degree in Network Circle's filter.
|
|
73
|
+
const [degree3, setDegree3] = useState([]);
|
|
74
|
+
// Which degrees Network Circle draws: your connections alone until you pick more.
|
|
75
|
+
const [degrees, setDegrees] = useState([1]);
|
|
71
76
|
const [selected, setSelected] = useState(null);
|
|
72
77
|
const focusNodeRef = useRef(null);
|
|
73
78
|
const [filter, setFilter] = useState('all');
|
|
@@ -102,7 +107,7 @@ function HomeInner() {
|
|
|
102
107
|
// imported in this tab (1st-degree only, never persisted), or Supabase.
|
|
103
108
|
const csv = hasCsvNetwork() ? loadCsvNetwork() : null;
|
|
104
109
|
if (csv) { setCsvMode(true); setCsvSource(csv.source || 'csv'); }
|
|
105
|
-
const { degree1: d1, degree2: d2 } = IS_DEMO
|
|
110
|
+
const { degree1: d1, degree2: d2, degree3: d3 = [] } = IS_DEMO
|
|
106
111
|
? await loadDemoNetwork()
|
|
107
112
|
: csv
|
|
108
113
|
? csv
|
|
@@ -112,6 +117,7 @@ function HomeInner() {
|
|
|
112
117
|
|
|
113
118
|
setDegree1(d1);
|
|
114
119
|
setDegree2(d2);
|
|
120
|
+
setDegree3(d3 || []);
|
|
115
121
|
shapeRef.current = networkShape(d1, d2);
|
|
116
122
|
setStats(statsFor(d1, d2));
|
|
117
123
|
setLoading(false);
|
|
@@ -131,8 +137,9 @@ function HomeInner() {
|
|
|
131
137
|
const reload = useCallback(async () => {
|
|
132
138
|
if (IS_DEMO || hasCsvNetwork() || !userId) return;
|
|
133
139
|
try {
|
|
134
|
-
const [{ degree1: d1, degree2: d2 }, notes] = await Promise.all([loadNetwork(userId), loadScanNotes()]);
|
|
140
|
+
const [{ degree1: d1, degree2: d2, degree3: d3 = [] }, notes] = await Promise.all([loadNetwork(userId), loadScanNotes()]);
|
|
135
141
|
setScanNotes((prev) => (JSON.stringify(prev) === JSON.stringify(notes) ? prev : notes));
|
|
142
|
+
setDegree3((prev) => (prev.length === (d3 || []).length ? prev : d3 || []));
|
|
136
143
|
const shape = networkShape(d1, d2);
|
|
137
144
|
if (shape === shapeRef.current) return;
|
|
138
145
|
shapeRef.current = shape;
|
|
@@ -156,9 +163,16 @@ function HomeInner() {
|
|
|
156
163
|
// scene when these change identity: computed inline, every re-render — a click,
|
|
157
164
|
// the sidebar opening, notifications arriving — reset the galaxy's layout. The
|
|
158
165
|
// empty list in network mode was a new [] each time, which was enough on its own.
|
|
166
|
+
// Network Circle draws the degrees picked in the Filter panel, each person
|
|
167
|
+
// once, at the nearest degree they're found (lib/degrees.js).
|
|
168
|
+
const byDegree = useMemo(() => peopleByDegree(degree1, degree2, degree3), [degree1, degree2, degree3]);
|
|
169
|
+
const networkRows = useMemo(
|
|
170
|
+
() => (isDegreesMode ? connections : degrees.flatMap((d) => byDegree[d] || [])),
|
|
171
|
+
[isDegreesMode, connections, degrees, byDegree],
|
|
172
|
+
);
|
|
159
173
|
const filtered = useMemo(
|
|
160
|
-
() => (filter === 'all' ?
|
|
161
|
-
[
|
|
174
|
+
() => (filter === 'all' ? networkRows : networkRows.filter(c => c.tier === filter)),
|
|
175
|
+
[networkRows, filter],
|
|
162
176
|
);
|
|
163
177
|
// Lookups built once per load. Every click re-renders this component, and the
|
|
164
178
|
// three places below used to scan one list inside another — degree1.find per
|
|
@@ -218,7 +232,7 @@ function HomeInner() {
|
|
|
218
232
|
{isMobile ? 'Circle' : 'Network Circle'}
|
|
219
233
|
</button>
|
|
220
234
|
<button
|
|
221
|
-
onClick={() => { setMode('degrees'); setSelected(null); setFilter('all'); setVisualMode('
|
|
235
|
+
onClick={() => { setMode('degrees'); setSelected(null); setFilter('all'); setVisualMode('chain'); }}
|
|
222
236
|
style={{
|
|
223
237
|
padding: isMobile ? '6px 10px' : '8px 16px', borderRadius: 6, border: 'none', fontSize: isMobile ? 11 : 13, fontWeight: 600, cursor: 'pointer',
|
|
224
238
|
background: mode === 'degrees' ? 'linear-gradient(135deg, #FFD700, #FF6B35)' : 'rgba(255,255,255,0.12)',
|
|
@@ -419,9 +433,12 @@ function HomeInner() {
|
|
|
419
433
|
mode={mode}
|
|
420
434
|
filter={filter}
|
|
421
435
|
onFilterChange={setFilter}
|
|
422
|
-
visualMode={
|
|
436
|
+
visualMode={view.key}
|
|
423
437
|
onVisualModeChange={setVisualMode}
|
|
424
|
-
tierCounts={stats?.tiers || {}}
|
|
438
|
+
tierCounts={isDegreesMode ? stats?.tiers || {} : tierCountsOf(networkRows)}
|
|
439
|
+
degrees={degrees}
|
|
440
|
+
onDegreesChange={setDegrees}
|
|
441
|
+
degreeCounts={{ 1: byDegree[1].length, 2: byDegree[2].length, 3: byDegree[3].length }}
|
|
425
442
|
bridgeTierCounts={bridgeTierCounts}
|
|
426
443
|
/>
|
|
427
444
|
{/* Visualization — switches based on visualMode */}
|
|
@@ -43,6 +43,8 @@ function PathsInner() {
|
|
|
43
43
|
useEffect(() => { if (asked === 'scores') router.replace('/scores#companies'); }, [asked, router]);
|
|
44
44
|
// Bumped when company scores change, so the network reloads with new tiers.
|
|
45
45
|
const [reloadKey, setReloadKey] = useState(0);
|
|
46
|
+
// The sample or a CSV import: they live only in this tab, so a company scan could only fail.
|
|
47
|
+
const [localOnly, setLocalOnly] = useState(false);
|
|
46
48
|
|
|
47
49
|
useEffect(() => {
|
|
48
50
|
if (IS_DEMO) return;
|
|
@@ -52,6 +54,7 @@ function PathsInner() {
|
|
|
52
54
|
// in this tab, not the database. Keep the sample's 2nd degree: without it
|
|
53
55
|
// the company map has no links and nobody is "reachable".
|
|
54
56
|
const csv = hasCsvNetwork() ? loadCsvNetwork() : null;
|
|
57
|
+
setLocalOnly(Boolean(csv));
|
|
55
58
|
let d1 = [], d2 = [], d3 = [];
|
|
56
59
|
if (csv) {
|
|
57
60
|
d1 = csv.degree1;
|
|
@@ -118,7 +121,7 @@ function PathsInner() {
|
|
|
118
121
|
}
|
|
119
122
|
|
|
120
123
|
async function scanFullCompany() {
|
|
121
|
-
if (IS_DEMO) return;
|
|
124
|
+
if (IS_DEMO || localOnly) return;
|
|
122
125
|
if (!selectedCompany) return;
|
|
123
126
|
// Company scans shipped in 0.1.0 without ever having been run against live
|
|
124
127
|
// LinkedIn (docs/brain/PHASES.md). Say so once per browser before the first
|
|
@@ -262,6 +265,9 @@ function PathsInner() {
|
|
|
262
265
|
<span style={{ fontSize: 11, color: '#888' }}>
|
|
263
266
|
{connectedCount}/{companyPeople.length} connected
|
|
264
267
|
</span>
|
|
268
|
+
{localOnly ? (
|
|
269
|
+
<span style={{ marginLeft: 'auto', fontSize: 11, color: '#888' }}>Scanning needs your own network</span>
|
|
270
|
+
) : (
|
|
265
271
|
<button onClick={scanFullCompany} disabled={scanning || Boolean(busy)}
|
|
266
272
|
title={busy ? `${busy}. One scan at a time.` : undefined}
|
|
267
273
|
style={{
|
|
@@ -271,6 +277,7 @@ function PathsInner() {
|
|
|
271
277
|
}}>
|
|
272
278
|
{scanning ? 'Scanning...' : busy ? 'Scanner busy' : '+ Scan Full Company'}
|
|
273
279
|
</button>
|
|
280
|
+
)}
|
|
274
281
|
<span
|
|
275
282
|
title="Not yet tested against live LinkedIn. Results may be incomplete."
|
|
276
283
|
style={{
|
|
@@ -134,6 +134,20 @@ CREATE TABLE IF NOT EXISTS company_scores (
|
|
|
134
134
|
|
|
135
135
|
-- Small key/value facts about this install, e.g. which scoring model the
|
|
136
136
|
-- stored scores were computed with.
|
|
137
|
+
-- Two of your own connections who know each other, seen when a circle scan
|
|
138
|
+
-- found one of them in the other's list (they're dropped from that circle,
|
|
139
|
+
-- being yours already). By profile URL, the smaller first, so a pair is one
|
|
140
|
+
-- row whichever circle showed it. No extra LinkedIn traffic: the scan read
|
|
141
|
+
-- them anyway. For clustering and communities later (lib/ties.js).
|
|
142
|
+
CREATE TABLE IF NOT EXISTS connection_ties (
|
|
143
|
+
id TEXT, -- lib/db.js gives every row one
|
|
144
|
+
user_id TEXT NOT NULL,
|
|
145
|
+
a_url TEXT NOT NULL,
|
|
146
|
+
b_url TEXT NOT NULL,
|
|
147
|
+
seen_at TEXT DEFAULT (datetime('now')),
|
|
148
|
+
PRIMARY KEY (user_id, a_url, b_url)
|
|
149
|
+
);
|
|
150
|
+
|
|
137
151
|
CREATE TABLE IF NOT EXISTS app_meta (
|
|
138
152
|
key TEXT PRIMARY KEY,
|
|
139
153
|
value TEXT
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// Who is irreplaceable among your bridges.
|
|
2
|
+
//
|
|
3
|
+
// A bridge's exclusive reach: for each person in their circle, 1 ÷ how many of
|
|
4
|
+
// your bridges reach that person, added up. Someone only Maya reaches counts 1
|
|
5
|
+
// to her; someone three bridges reach counts a third to each. It's ego
|
|
6
|
+
// betweenness (Everett & Borgatti 2005), which on this network's shape (you →
|
|
7
|
+
// your connections → their circles) gives Brandes' betweenness, in one pass.
|
|
8
|
+
// `only` is how many nobody else reaches: "212 of your people only through
|
|
9
|
+
// Maya". People who are your connections already need no bridge, so they
|
|
10
|
+
// don't count.
|
|
11
|
+
//
|
|
12
|
+
// Plain functions, no React; tests/brokerage.test.mjs.
|
|
13
|
+
|
|
14
|
+
import { keyFor } from './separation.js';
|
|
15
|
+
|
|
16
|
+
/** Map of bridge id → { reach, only, total }, from the rows the views already have. */
|
|
17
|
+
export function exclusiveReach(degree2 = [], connections = []) {
|
|
18
|
+
const yours = new Set((connections || []).map(keyFor));
|
|
19
|
+
const bridgesOf = new Map(); // person → the bridges who reach them
|
|
20
|
+
for (const row of degree2 || []) {
|
|
21
|
+
const bridge = row?.source_connection_id;
|
|
22
|
+
if (bridge == null) continue;
|
|
23
|
+
const key = keyFor(row);
|
|
24
|
+
if (yours.has(key)) continue;
|
|
25
|
+
let set = bridgesOf.get(key);
|
|
26
|
+
if (!set) bridgesOf.set(key, (set = new Set()));
|
|
27
|
+
set.add(bridge);
|
|
28
|
+
}
|
|
29
|
+
const out = new Map();
|
|
30
|
+
for (const set of bridgesOf.values()) {
|
|
31
|
+
for (const bridge of set) {
|
|
32
|
+
const r = out.get(bridge) || { reach: 0, only: 0, total: 0 };
|
|
33
|
+
r.reach += 1 / set.size;
|
|
34
|
+
r.total += 1;
|
|
35
|
+
if (set.size === 1) r.only += 1;
|
|
36
|
+
out.set(bridge, r);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return out;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Which of your connections open the same doors: for each, the one whose
|
|
44
|
+
* circle overlaps theirs most, as the share of people either reaches that both
|
|
45
|
+
* do (Jaccard). "Maya and Tom open the same doors (62%)". Pairs are counted
|
|
46
|
+
* from the people reached two or more ways, so it stays quick.
|
|
47
|
+
*
|
|
48
|
+
* @returns Map of bridge id → { with, share, shared }
|
|
49
|
+
*/
|
|
50
|
+
export function bridgeOverlap(degree2 = [], connections = []) {
|
|
51
|
+
const reach = exclusiveReach(degree2, connections);
|
|
52
|
+
const yours = new Set((connections || []).map(keyFor));
|
|
53
|
+
const bridgesOf = new Map();
|
|
54
|
+
for (const row of degree2 || []) {
|
|
55
|
+
const bridge = row?.source_connection_id;
|
|
56
|
+
if (bridge == null) continue;
|
|
57
|
+
const key = keyFor(row);
|
|
58
|
+
if (yours.has(key)) continue;
|
|
59
|
+
let set = bridgesOf.get(key);
|
|
60
|
+
if (!set) bridgesOf.set(key, (set = new Set()));
|
|
61
|
+
set.add(bridge);
|
|
62
|
+
}
|
|
63
|
+
const shared = new Map(); // "a|b" → people both reach
|
|
64
|
+
for (const set of bridgesOf.values()) {
|
|
65
|
+
if (set.size < 2) continue;
|
|
66
|
+
const list = [...set];
|
|
67
|
+
for (let i = 0; i < list.length; i++) {
|
|
68
|
+
for (let j = i + 1; j < list.length; j++) {
|
|
69
|
+
const k = list[i] < list[j] ? `${list[i]}|${list[j]}` : `${list[j]}|${list[i]}`;
|
|
70
|
+
shared.set(k, (shared.get(k) || 0) + 1);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
const best = new Map();
|
|
75
|
+
for (const [k, both] of shared) {
|
|
76
|
+
const [a, b] = k.split('|');
|
|
77
|
+
const union = reach.get(a).total + reach.get(b).total - both;
|
|
78
|
+
const share = union ? both / union : 0;
|
|
79
|
+
for (const [me, other] of [[a, b], [b, a]]) {
|
|
80
|
+
const cur = best.get(me);
|
|
81
|
+
if (!cur || share > cur.share) best.set(me, { with: other, share, shared: both });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return best;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Of everyone you reach through a circle, the share you reach two or more ways. */
|
|
88
|
+
export function redundancy(degree2 = [], connections = []) {
|
|
89
|
+
const reach = exclusiveReach(degree2, connections);
|
|
90
|
+
let people = 0;
|
|
91
|
+
let only = 0;
|
|
92
|
+
for (const r of reach.values()) { people += r.reach; only += r.only; }
|
|
93
|
+
return people ? 1 - only / people : 0;
|
|
94
|
+
}
|
|
@@ -137,3 +137,92 @@ export function dotRadius(spacing, tier) {
|
|
|
137
137
|
const scale = tier === 'S' ? 1 : tier === 'A' ? 0.86 : tier === 'B' ? 0.72 : 0.6;
|
|
138
138
|
return Math.max(1.4, Math.min(7, spacing * 0.42 * scale));
|
|
139
139
|
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Where a bridge's circle is previewed on hover: a band beyond every ring of
|
|
143
|
+
* bridges. With many bridges the overview grows extra rings, past where the
|
|
144
|
+
* preview used to start, and it drew over them. `clear` leaves room for a
|
|
145
|
+
* hovered bridge's dot (20) and its name under it.
|
|
146
|
+
*/
|
|
147
|
+
export function previewBand(maxR, bridgeRings = [], clear = 34) {
|
|
148
|
+
const edge = Math.max(0, ...bridgeRings.map((r) => r.radius));
|
|
149
|
+
const inner = Math.max(maxR * 0.66, edge ? edge + clear : 0);
|
|
150
|
+
return { inner, outer: Math.max(maxR * 0.8, inner + maxR * 0.14) };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const TIER_BANDS = ['S', 'A', 'B', 'C', 'D'];
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* An opened circle's people in tier bands round the person: S nearest, then A,
|
|
157
|
+
* B, C and D, with a gap between bands like Network Circle's tier orbits, so a
|
|
158
|
+
* big circle reads as layers instead of one clump. Points come back in the
|
|
159
|
+
* order of `rows`, each with `slot`: the angle its ring gives each dot. The
|
|
160
|
+
* spacing closes up, down to `minSpacing`, until every band fits inside
|
|
161
|
+
* `outer`; past that the bands carry on outwards and zooming out shows them.
|
|
162
|
+
*
|
|
163
|
+
* @returns {{ spacing: number, edge: number, bands: { tier: string, inner: number, outer: number }[],
|
|
164
|
+
* points: { x: number, y: number, angle: number, slot: number }[] }}
|
|
165
|
+
*/
|
|
166
|
+
export function tierBandLayout(rows, { inner, outer, spacing, minSpacing = spacing / 3, gap = spacing }) {
|
|
167
|
+
const groups = TIER_BANDS.map((tier) => ({ tier, idx: [] }));
|
|
168
|
+
rows.forEach((row, i) => {
|
|
169
|
+
const t = TIER_BANDS.indexOf(row?.tier);
|
|
170
|
+
groups[t < 0 ? 4 : t].idx.push(i); // no tier: with D
|
|
171
|
+
});
|
|
172
|
+
const used = groups.filter((g) => g.idx.length);
|
|
173
|
+
const place = (s) => {
|
|
174
|
+
const points = new Array(rows.length);
|
|
175
|
+
const bands = [];
|
|
176
|
+
let r = inner;
|
|
177
|
+
for (const g of used) {
|
|
178
|
+
const l = ringLayout(g.idx.length, { inner: r, outer: Infinity, spacing: s, minSpacing: s });
|
|
179
|
+
g.idx.forEach((rowIndex, j) => {
|
|
180
|
+
const p = l.points[j];
|
|
181
|
+
points[rowIndex] = { x: p.x, y: p.y, angle: p.angle, slot: TAU / l.rings[p.ring].count };
|
|
182
|
+
});
|
|
183
|
+
const edge = Math.max(r, ...l.rings.map((x) => x.radius));
|
|
184
|
+
bands.push({ tier: g.tier, inner: r, outer: edge });
|
|
185
|
+
r = edge + gap * (s / spacing);
|
|
186
|
+
}
|
|
187
|
+
return { spacing: s, points, bands, edge: bands.length ? bands[bands.length - 1].outer : inner };
|
|
188
|
+
};
|
|
189
|
+
let best = place(spacing);
|
|
190
|
+
if (best.edge <= outer) return best;
|
|
191
|
+
let lo = minSpacing;
|
|
192
|
+
let hi = spacing;
|
|
193
|
+
best = place(lo);
|
|
194
|
+
if (best.edge > outer) return best;
|
|
195
|
+
for (let i = 0; i < 20; i++) {
|
|
196
|
+
const mid = (lo + hi) / 2;
|
|
197
|
+
const tryMid = place(mid);
|
|
198
|
+
if (tryMid.edge <= outer) { lo = mid; best = tryMid; } else hi = mid;
|
|
199
|
+
}
|
|
200
|
+
return best;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The people behind each person in an opened circle: their own circle, drawn
|
|
205
|
+
* as a fan of small dots beyond `from`, centred on that person's angle and no
|
|
206
|
+
* wider than their slot (at most a sixth of the circle), so each fan sits
|
|
207
|
+
* behind its own person. At most `cap` dots a fan and about `total` in all, so
|
|
208
|
+
* a big network stays quick; `more` is how many weren't drawn.
|
|
209
|
+
*
|
|
210
|
+
* @param {{ angle: number, slot: number, count: number }[]} anchors
|
|
211
|
+
* @returns {({ points: { x: number, y: number }[], more: number } | null)[]}
|
|
212
|
+
*/
|
|
213
|
+
export function outerFans(anchors, { from, spacing = 7, cap = 40, total = 1200 } = {}) {
|
|
214
|
+
const fans = anchors.filter((a) => a?.count > 0).length;
|
|
215
|
+
const each = Math.max(6, Math.min(cap, Math.floor(total / Math.max(1, fans))));
|
|
216
|
+
return anchors.map((a) => {
|
|
217
|
+
if (!(a?.count > 0)) return null;
|
|
218
|
+
const n = Math.min(a.count, each);
|
|
219
|
+
// Only as wide as the dots need at `spacing`, never wider than their slot:
|
|
220
|
+
// more rows once it's full, so a small fan stays a tight cluster.
|
|
221
|
+
const room = Math.min(a.slot || TAU, TAU / 6);
|
|
222
|
+
const width = Math.min(room, Math.max((spacing * 3) / from, (n * spacing) / from));
|
|
223
|
+
const l = ringLayout(n, {
|
|
224
|
+
inner: from, outer: Infinity, spacing, minSpacing: spacing, start: a.angle - width / 2, sweep: width,
|
|
225
|
+
});
|
|
226
|
+
return { points: l.points.map(({ x, y }) => ({ x, y })), more: a.count - n };
|
|
227
|
+
});
|
|
228
|
+
}
|
|
@@ -137,17 +137,34 @@ export function circleLayout(rings = [], size = 240) {
|
|
|
137
137
|
for (let k = 0; k < R; k++) {
|
|
138
138
|
const radius = radiusOf(k);
|
|
139
139
|
const people = rings[k].people;
|
|
140
|
-
|
|
140
|
+
// More than their slice holds without the dots touching: rows, neighbours
|
|
141
|
+
// alternating between them, so a big circle never draws as a solid line.
|
|
142
|
+
// Each keeps its angle behind who they hang off. Rows go out towards the
|
|
143
|
+
// next ring, or for the outermost, in towards the one before (less than
|
|
144
|
+
// half way either side, so two rings' rows never meet).
|
|
145
|
+
const gap = Math.max(2.4, size / 100);
|
|
146
|
+
const inward = k === R - 1 && R > 1;
|
|
147
|
+
const band = (inward ? radius - radiusOf(k - 1) : (k + 1 < R ? radiusOf(k + 1) : half * 0.97) - radius) * 0.45;
|
|
148
|
+
const maxRows = Math.max(1, Math.floor(band / gap) + 1);
|
|
149
|
+
const rowGap = maxRows > 1 ? (inward ? -1 : 1) * Math.min(gap * 1.2, band / (maxRows - 1)) : 0;
|
|
150
|
+
const rowsFor = (count, arc) => clamp(Math.ceil((count * gap) / Math.max(1e-9, radius * arc)), 1, maxRows);
|
|
151
|
+
let rows = 1;
|
|
152
|
+
let room = Infinity; // the least room any one dot gets along its row
|
|
141
153
|
const items = [];
|
|
142
154
|
for (const [parentId, group] of byParent[k]) {
|
|
143
155
|
const [a0, a1] = k === 0 ? [START, START + 2 * Math.PI] : span.get(parentId) || [START, START + 2 * Math.PI];
|
|
144
156
|
const total = group.reduce((s, p) => s + weight.get(p), 0);
|
|
157
|
+
const groupRows = rowsFor(group.length, a1 - a0);
|
|
158
|
+
rows = Math.max(rows, groupRows);
|
|
159
|
+
room = Math.min(room, (radius * (a1 - a0) * groupRows) / Math.max(1, group.length));
|
|
160
|
+
let n = 0;
|
|
145
161
|
let a = a0;
|
|
146
162
|
for (const p of group) {
|
|
147
163
|
const w = ((a1 - a0) * weight.get(p)) / total;
|
|
148
164
|
const angle = a + w / 2;
|
|
149
|
-
const
|
|
150
|
-
const
|
|
165
|
+
const r = radius + (n++ % groupRows) * rowGap;
|
|
166
|
+
const x = half + r * Math.cos(angle);
|
|
167
|
+
const y = half + r * Math.sin(angle);
|
|
151
168
|
items.push({ ...p, angle, x, y });
|
|
152
169
|
if (p.state === 'connected') span.set(p.row.id, [a, a + w]);
|
|
153
170
|
a += w;
|
|
@@ -163,7 +180,9 @@ export function circleLayout(rings = [], size = 240) {
|
|
|
163
180
|
spokes.push({ x1: it.x, y1: it.y, x2: half + nextR * Math.cos(mid), y2: half + nextR * Math.sin(mid) });
|
|
164
181
|
}
|
|
165
182
|
}
|
|
166
|
-
|
|
183
|
+
// Sized by the most crowded slice, and never wider than the gap between rows.
|
|
184
|
+
const dotR = clamp(Math.min(room * 0.32, rows > 1 ? Math.abs(rowGap) * 0.42 : Infinity), 0.8, size / 60);
|
|
185
|
+
out.push({ degree: rings[k].degree, radius, dotR, rows, items });
|
|
167
186
|
}
|
|
168
187
|
return { size, center: { x: half, y: half }, rings: out, spokes };
|
|
169
188
|
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// Everyone the app knows, by degree, for Network Circle's degree filter.
|
|
2
|
+
//
|
|
3
|
+
// 1st: your connections. 2nd: anyone in a scanned circle, including the people
|
|
4
|
+
// behind someone you added, since you're connected to them now. 3rd: anyone a
|
|
5
|
+
// company scan found who is neither. Each person counts once, at the nearest
|
|
6
|
+
// degree they're found (by profile, lib/separation.js keyFor), whichever
|
|
7
|
+
// circle or scan found them first.
|
|
8
|
+
|
|
9
|
+
import { keyFor } from './separation.js';
|
|
10
|
+
|
|
11
|
+
export function peopleByDegree(degree1 = [], degree2 = [], degree3 = []) {
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const take = (rows, degree) => {
|
|
14
|
+
const out = [];
|
|
15
|
+
for (const row of rows || []) {
|
|
16
|
+
const key = keyFor(row);
|
|
17
|
+
if (seen.has(key)) continue;
|
|
18
|
+
seen.add(key);
|
|
19
|
+
out.push(row.degree === degree ? row : { ...row, degree });
|
|
20
|
+
}
|
|
21
|
+
return out;
|
|
22
|
+
};
|
|
23
|
+
return { 1: take(degree1, 1), 2: take(degree2, 2), 3: take(degree3, 3) };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function tierCountsOf(rows = []) {
|
|
27
|
+
const counts = {};
|
|
28
|
+
for (const row of rows) counts[row.tier] = (counts[row.tier] || 0) + 1;
|
|
29
|
+
return counts;
|
|
30
|
+
}
|
|
@@ -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' };
|
|
@@ -31,12 +31,12 @@ export function uniqueByProfile(records) {
|
|
|
31
31
|
*/
|
|
32
32
|
export function splitAlreadyConnected(records, firstDegreeUrls) {
|
|
33
33
|
const keep = [];
|
|
34
|
-
|
|
34
|
+
const connected = []; // their profile URLs: ties between your connections (lib/ties.js)
|
|
35
35
|
for (const r of records) {
|
|
36
|
-
if (firstDegreeUrls.has(r.profile_url))
|
|
36
|
+
if (firstDegreeUrls.has(r.profile_url)) connected.push(r.profile_url);
|
|
37
37
|
else keep.push(r);
|
|
38
38
|
}
|
|
39
|
-
return { keep, alreadyConnected };
|
|
39
|
+
return { keep, alreadyConnected: connected.length, connected };
|
|
40
40
|
}
|
|
41
41
|
|
|
42
42
|
const MONTHS = ['january', 'february', 'march', 'april', 'may', 'june', 'july',
|
|
@@ -5,8 +5,9 @@
|
|
|
5
5
|
// changes here — the budget, and lifting a cooldown.
|
|
6
6
|
//
|
|
7
7
|
// The budget is searches (a day and a month) and profile views (a day). Profile
|
|
8
|
-
// views
|
|
9
|
-
//
|
|
8
|
+
// views got an account restricted (TRAPS §16), so their cap has no "no limit",
|
|
9
|
+
// and the scanner also keeps any two a minute apart. Searches did too, at 373 in
|
|
10
|
+
// 24 hours: the page asks before a risky search budget (lib/search-risk.js).
|
|
10
11
|
//
|
|
11
12
|
// They belong to the LinkedIn account, not to a profile in the app. TRAPS §35.
|
|
12
13
|
|
|
@@ -58,14 +59,18 @@ export function nextMonthStartPacific(ms = Date.now()) {
|
|
|
58
59
|
return monthStartPacific(start + 40 * DAY * 1000);
|
|
59
60
|
}
|
|
60
61
|
|
|
62
|
+
const profileChoice = (n) => PROFILE_CHOICES.filter((c) => c <= n).pop() ?? PROFILE_CHOICES[0];
|
|
63
|
+
|
|
61
64
|
export function readLimits(dir) {
|
|
62
65
|
const raw = readJson(path.join(dir, 'scan-limits.json')) || {};
|
|
63
66
|
const num = (v, d) => (Number.isInteger(v) && v >= 0 ? v : d);
|
|
64
67
|
return {
|
|
65
68
|
daily: num(raw.daily, DEFAULT_LIMITS.daily),
|
|
66
69
|
monthly: num(raw.monthly, DEFAULT_LIMITS.monthly),
|
|
67
|
-
// 0 is "no limit" for searches; for profile views the scanner reads it as the
|
|
68
|
-
|
|
70
|
+
// 0 is "no limit" for searches; for profile views the scanner reads it as the
|
|
71
|
+
// default. A number typed in by hand counts as the largest choice under it,
|
|
72
|
+
// as the scanner reads it (scrape.py search_limits).
|
|
73
|
+
profiles: profileChoice(num(raw.profiles, 0) || DEFAULT_LIMITS.profiles),
|
|
69
74
|
};
|
|
70
75
|
}
|
|
71
76
|
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// How much searching is asking for trouble, for the Scan page's budget picker.
|
|
2
|
+
// No Node imports, so the page and the tests share it (lib/linkedin-limits.js
|
|
3
|
+
// reads files and can't load in the browser).
|
|
4
|
+
//
|
|
5
|
+
// The number behind it (TRAPS §16): on 2026-09-28 a real account was restricted
|
|
6
|
+
// after 373 searches in 24 hours, run back to back, with the daily budget raised
|
|
7
|
+
// to 500 and no monthly cap. It opened 7 profiles that day, so searches alone
|
|
8
|
+
// were enough.
|
|
9
|
+
|
|
10
|
+
export const RISKY_DAILY = 100; // above this, the picker asks first
|
|
11
|
+
export const RESTRICTED_AT = 373; // searches in 24 hours when that account was restricted
|
|
12
|
+
export const SAFE_LIMITS = { daily: 50, monthly: 250 };
|
|
13
|
+
|
|
14
|
+
export const riskyDaily = (n) => Number(n) > RISKY_DAILY;
|
|
15
|
+
export const riskyLimits = ({ daily, monthly } = {}) => riskyDaily(daily) || monthly === 0;
|
|
16
|
+
|
|
17
|
+
/** What the picker asks before a change that raises the risk; null when there's nothing to ask. */
|
|
18
|
+
export function limitQuestion(from = {}, to = {}) {
|
|
19
|
+
if (riskyDaily(to.daily) && Number(to.daily) > Number(from.daily)) {
|
|
20
|
+
return `${to.daily} searches a day is more than LinkedIn has put up with. A real account was `
|
|
21
|
+
+ `restricted after ${RESTRICTED_AT} searches in 24 hours, run back to back. The default is `
|
|
22
|
+
+ `${SAFE_LIMITS.daily} a day.\n\nSet ${to.daily} a day anyway?`;
|
|
23
|
+
}
|
|
24
|
+
if (to.monthly === 0 && from.monthly !== 0) {
|
|
25
|
+
return 'With no monthly cap, only the daily budget stops a scan. A real account was restricted '
|
|
26
|
+
+ `after ${RESTRICTED_AT} searches in 24 hours with no monthly cap set.\n\nTurn the monthly cap off anyway?`;
|
|
27
|
+
}
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** The note under the picker while the budget is risky; null when it isn't. */
|
|
32
|
+
export function limitNote({ daily, monthly } = {}) {
|
|
33
|
+
if (!riskyLimits({ daily, monthly })) return null;
|
|
34
|
+
const parts = [`A real account was restricted after ${RESTRICTED_AT} searches in 24 hours.`];
|
|
35
|
+
if (riskyDaily(daily)) parts.push(`${daily} a day can use up a free account's month in a day or two.`);
|
|
36
|
+
if (monthly === 0) parts.push('With no monthly cap, nothing but the daily budget stops a long run.');
|
|
37
|
+
return parts.join(' ');
|
|
38
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Ties between your own connections: two people you know who know each other.
|
|
2
|
+
//
|
|
3
|
+
// A circle scan reads everyone in someone's list, your own connections among
|
|
4
|
+
// them, and the app sets those aside rather than saving them as 2nd degree
|
|
5
|
+
// (lib/ingest.js splitAlreadyConnected). Each one is a tie between that
|
|
6
|
+
// person and the one whose circle it is. Kept in connection_ties (db/schema.js),
|
|
7
|
+
// for clustering and communities later; nothing reads them yet.
|
|
8
|
+
|
|
9
|
+
/** The pairs to keep: the circle's person with each of your connections in it, each pair once, smaller URL first. */
|
|
10
|
+
export function tiePairs(ownerUrl, urls = []) {
|
|
11
|
+
const seen = new Set();
|
|
12
|
+
const out = [];
|
|
13
|
+
if (!ownerUrl) return out;
|
|
14
|
+
for (const url of urls) {
|
|
15
|
+
if (!url || url === ownerUrl) continue;
|
|
16
|
+
const [a, b] = url < ownerUrl ? [url, ownerUrl] : [ownerUrl, url];
|
|
17
|
+
const key = `${a} ${b}`;
|
|
18
|
+
if (seen.has(key)) continue;
|
|
19
|
+
seen.add(key);
|
|
20
|
+
out.push({ a_url: a, b_url: b });
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "six-degrees",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "six-degrees",
|
|
9
|
-
"version": "0.4.
|
|
9
|
+
"version": "0.4.2",
|
|
10
10
|
"license": "MIT",
|
|
11
11
|
"os": [
|
|
12
12
|
"darwin",
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "six-degrees",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"description": "Map your professional network as a galaxy — leverage tiers, bridges into new circles, and the shortest path to anyone. Runs locally on your own data.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Blake Burford",
|