six-degrees 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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.html +1 -1
- package/.next/standalone/.next/server/app/profile.rsc +2 -2
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +2 -2
- 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 +1 -1
- package/.next/standalone/.next/server/app/queue/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/queue.html +1 -1
- package/.next/standalone/.next/server/app/queue.rsc +2 -2
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +2 -2
- 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 +1 -1
- 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/_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/app_page_1fazqbf.js +3 -3
- 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/10784r7w7t5gw.js +18 -0
- package/.next/standalone/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
- package/.next/standalone/.next/static/chunks/3ol1xux1-ofm4.js +6 -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 +59 -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/components/ChainView.js +68 -14
- package/.next/standalone/app/components/FilterPanel.js +37 -1
- package/.next/standalone/app/components/ForceGraph.js +44 -5
- package/.next/standalone/app/components/LinkedInLimits.js +20 -3
- 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/ingest.js +3 -3
- package/.next/standalone/lib/linkedin-limits.js +9 -4
- 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 +40 -12
- package/.next/static/chunks/10784r7w7t5gw.js +18 -0
- package/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
- package/.next/static/chunks/3ol1xux1-ofm4.js +6 -0
- package/.next/static/chunks/3zs_6-nn0dapb.js +1 -0
- package/.next/static/media/favicon.0zdzw5kurn8m-.ico +0 -0
- package/CHANGELOG.md +59 -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/ingest.js +3 -3
- package/lib/linkedin-limits.js +9 -4
- package/lib/search-risk.js +38 -0
- package/lib/ties.js +23 -0
- package/package.json +1 -1
- package/scripts/scrape.py +40 -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/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/3qh78g2aqhprd.js +0 -2
- package/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
- /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_ssgManifest.js +0 -0
- /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_buildManifest.js +0 -0
- /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{WseE_5HuU-1q0ywC9CP3U → LlDgA9g8ToBkDLlo9Qtov}/_ssgManifest.js +0 -0
|
@@ -32,7 +32,8 @@ import { useRouter } from 'next/navigation';
|
|
|
32
32
|
import { circleIndex } from '../../lib/circle';
|
|
33
33
|
import { localPhoto } from '../../lib/photos';
|
|
34
34
|
import { reachIndex, reachState, circleState, readyByCircle, circleScanCost } from '../../lib/reach';
|
|
35
|
-
import { ringLayout, dotRadius } from '../../lib/chain-layout';
|
|
35
|
+
import { ringLayout, dotRadius, previewBand, tierBandLayout, outerFans } from '../../lib/chain-layout';
|
|
36
|
+
import { exclusiveReach, redundancy } from '../../lib/brokerage';
|
|
36
37
|
import { keyFor, score } from '../../lib/separation';
|
|
37
38
|
import { hasRequest } from '../../lib/requests-client';
|
|
38
39
|
import { watchScanner, scannerNow, isCircleScan } from '../../lib/scraper-client';
|
|
@@ -138,6 +139,9 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
|
|
|
138
139
|
const index = useMemo(() => circleIndex(all1, all2), [all1, all2]);
|
|
139
140
|
const reach = useMemo(() => reachIndex(all1, all2, scanNotes), [all1, all2, scanNotes]);
|
|
140
141
|
const readyCount = useMemo(() => readyByCircle(reach), [reach]);
|
|
142
|
+
// Who only this bridge reaches, across every circle scanned (lib/brokerage.js).
|
|
143
|
+
const exclusive = useMemo(() => exclusiveReach(fullDegree2 || degree2, fullDegree1 || connections), [fullDegree2, degree2, fullDegree1, connections]);
|
|
144
|
+
const twoWays = useMemo(() => redundancy(fullDegree2 || degree2, fullDegree1 || connections), [fullDegree2, degree2, fullDegree1, connections]);
|
|
141
145
|
const rowById = useMemo(() => {
|
|
142
146
|
const m = new Map();
|
|
143
147
|
for (const r of all2) m.set(r.id, r);
|
|
@@ -271,7 +275,7 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
|
|
|
271
275
|
{/* Their circle, previewed on hover, in a wedge that grows rows as it fills */}
|
|
272
276
|
{hovBridge && (
|
|
273
277
|
<CirclePreview bridge={hovBridge} members={membersOf(hovBridge, index)} reach={reach}
|
|
274
|
-
cx={cx} cy={cy} maxR={maxR} still={still} />
|
|
278
|
+
cx={cx} cy={cy} maxR={maxR} still={still} band={previewBand(maxR, bridgeLayout.rings)} />
|
|
275
279
|
)}
|
|
276
280
|
|
|
277
281
|
{/* Bridge nodes — hover to preview their circle, click to open it */}
|
|
@@ -325,6 +329,12 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
|
|
|
325
329
|
<text x={b.x} y={b.y + (isHov ? 34 : 32)} textAnchor="middle" fill="#888" fontSize={7}>
|
|
326
330
|
{b.clusterSize} · {sCount > 0 ? sCount + 'S ' : ''}{aCount > 0 ? aCount + 'A' : ''}
|
|
327
331
|
</text>
|
|
332
|
+
{exclusive.get(b.id)?.only > 0 && (
|
|
333
|
+
<text x={b.x} y={b.y + (isHov ? 43 : 41)} textAnchor="middle" fill="#00ff88" fillOpacity={0.75} fontSize={6.5}>
|
|
334
|
+
{exclusive.get(b.id).only.toLocaleString()} only here
|
|
335
|
+
<title>{`${exclusive.get(b.id).only.toLocaleString()} of the people in ${b.name?.split(' ')[0]}’s circle are reached by none of your other connections`}</title>
|
|
336
|
+
</text>
|
|
337
|
+
)}
|
|
328
338
|
</g>
|
|
329
339
|
);
|
|
330
340
|
})}
|
|
@@ -372,6 +382,12 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
|
|
|
372
382
|
<div style={{ fontSize: 9, color: '#555', marginTop: 8 }}>
|
|
373
383
|
{bridges.length} bridges · {d2S} S + {d2A} A at 2nd degree
|
|
374
384
|
</div>
|
|
385
|
+
{twoWays > 0 && (
|
|
386
|
+
<div style={{ fontSize: 9, color: '#555', marginTop: 4 }}
|
|
387
|
+
title="The share of everyone you reach through a circle that more than one of your connections reaches">
|
|
388
|
+
{Math.round(twoWays * 100)}% of your 2nd degree you reach two or more ways
|
|
389
|
+
</div>
|
|
390
|
+
)}
|
|
375
391
|
<div style={{ fontSize: 8, color: '#444', marginTop: 4 }}>Click a bridge to open their circle, then anyone in it to open theirs</div>
|
|
376
392
|
</div>
|
|
377
393
|
</div>
|
|
@@ -379,13 +395,13 @@ export default function ChainView({ connections, degree2 = [], onSelect, userNam
|
|
|
379
395
|
}
|
|
380
396
|
|
|
381
397
|
/** A bridge's circle on hover: the people you reached through it first, then their S, A and B. */
|
|
382
|
-
function CirclePreview({ bridge, members, reach, cx, cy, maxR, still }) {
|
|
398
|
+
function CirclePreview({ bridge, members, reach, cx, cy, maxR, still, band }) {
|
|
383
399
|
const shown = members.filter((m) => m.degree === 1 || m.tier === 'S' || m.tier === 'A' || m.tier === 'B');
|
|
384
|
-
// Beyond
|
|
385
|
-
const inner
|
|
386
|
-
const sweep = Math.min(Math.PI * 0.9, Math.max(Math.PI * 0.2, (shown.length * 11) /
|
|
400
|
+
// Beyond every ring of bridges (lib/chain-layout.js previewBand).
|
|
401
|
+
const { inner, outer } = band;
|
|
402
|
+
const sweep = Math.min(Math.PI * 0.9, Math.max(Math.PI * 0.2, (shown.length * 11) / inner));
|
|
387
403
|
const layout = ringLayout(shown.length, {
|
|
388
|
-
inner, outer
|
|
404
|
+
inner, outer, spacing: 11, minSpacing: 3.5, start: bridge.angle - sweep / 2, sweep,
|
|
389
405
|
});
|
|
390
406
|
return (
|
|
391
407
|
<g>
|
|
@@ -473,10 +489,25 @@ function CircleFocus({ trail, index, reach, dims, requests, scanningId, still, c
|
|
|
473
489
|
const cx = dims.w / 2;
|
|
474
490
|
const cy = dims.h / 2;
|
|
475
491
|
const maxR = Math.min(cx, cy) - 30;
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
492
|
+
// Whose own circle is known, drawn behind them (their people, one degree
|
|
493
|
+
// further out): anyone you're connected to whose circle was scanned, or who
|
|
494
|
+
// introduced you to someone.
|
|
495
|
+
const behind = useMemo(() => members.map((row) => (row.degree === 1 ? membersOf(row, index) : [])), [members, index]);
|
|
496
|
+
const hasFans = behind.some((people) => people.length > 0);
|
|
497
|
+
// Tier bands round them, S nearest, like Network Circle's orbits; with fans
|
|
498
|
+
// to draw, the bands leave the outside of the circle for them.
|
|
499
|
+
const layout = useMemo(() => tierBandLayout(members, {
|
|
500
|
+
inner: Math.max(62, maxR * 0.3), outer: maxR * (hasFans ? 0.72 : 0.94),
|
|
501
|
+
spacing: Math.max(14, Math.min(24, maxR * 0.085)), minSpacing: 4, gap: Math.max(10, maxR * 0.04),
|
|
502
|
+
}), [members, maxR, hasFans]);
|
|
503
|
+
const fans = useMemo(() => {
|
|
504
|
+
if (!hasFans) return [];
|
|
505
|
+
const placed = outerFans(
|
|
506
|
+
members.map((row, i) => ({ angle: layout.points[i].angle, slot: layout.points[i].slot, count: behind[i].length })),
|
|
507
|
+
{ from: layout.edge + layout.spacing * 1.5 },
|
|
508
|
+
);
|
|
509
|
+
return placed.map((f, i) => f && { ...f, i, people: behind[i].slice(0, f.points.length) }).filter(Boolean);
|
|
510
|
+
}, [members, behind, hasFans, layout]);
|
|
480
511
|
|
|
481
512
|
// What each dot is: someone you reached (ready, hidden, scanned) or a
|
|
482
513
|
// 2nd-degree row, maybe with a request out; and how big their own circle is.
|
|
@@ -608,10 +639,33 @@ function CircleFocus({ trail, index, reach, dims, requests, scanningId, still, c
|
|
|
608
639
|
<filter id="focusGlow"><feGaussianBlur stdDeviation="4" result="blur" /><feMerge><feMergeNode in="blur" /><feMergeNode in="SourceGraphic" /></feMerge></filter>
|
|
609
640
|
</defs>
|
|
610
641
|
<g transform={`translate(${cx + view.x} ${cy + view.y}) scale(${k})`}>
|
|
611
|
-
{/*
|
|
612
|
-
{layout.
|
|
613
|
-
<circle key={
|
|
642
|
+
{/* A faint band behind each tier's dots, in its colour */}
|
|
643
|
+
{layout.bands.map((band) => (
|
|
644
|
+
<circle key={band.tier} r={(band.inner + band.outer) / 2} fill="none"
|
|
645
|
+
stroke={TIER_COLORS[band.tier] || '#555'} strokeOpacity={0.07}
|
|
646
|
+
strokeWidth={Math.max(1 / k, band.outer - band.inner + layout.spacing)} pointerEvents="none" />
|
|
614
647
|
))}
|
|
648
|
+
{/* Behind anyone whose circle is known: their people, a faint line to each */}
|
|
649
|
+
{fans.map((fan) => {
|
|
650
|
+
const a = layout.points[fan.i];
|
|
651
|
+
const last = fan.points[fan.points.length - 1];
|
|
652
|
+
return (
|
|
653
|
+
<g key={'fan-' + members[fan.i].id} pointerEvents="none">
|
|
654
|
+
{fan.points.map((p, j) => (
|
|
655
|
+
<line key={'l' + j} x1={a.x} y1={a.y} x2={p.x} y2={p.y}
|
|
656
|
+
stroke={GREEN} strokeOpacity={0.12} strokeWidth={0.5 / k} />
|
|
657
|
+
))}
|
|
658
|
+
{fan.points.map((p, j) => (
|
|
659
|
+
<circle key={'d' + j} cx={p.x} cy={p.y} r={2.2}
|
|
660
|
+
fill={TIER_COLORS[fan.people[j]?.tier] || '#555'} fillOpacity={0.75} />
|
|
661
|
+
))}
|
|
662
|
+
{fan.more > 0 && last && (
|
|
663
|
+
<text x={last.x * 1.06} y={last.y * 1.06} textAnchor="middle" fontSize={8 / k} fill="#8b9a9a"
|
|
664
|
+
stroke="#0a0a1a" strokeWidth={2.4 / k} paintOrder="stroke">+{fan.more.toLocaleString()}</text>
|
|
665
|
+
)}
|
|
666
|
+
</g>
|
|
667
|
+
);
|
|
668
|
+
})}
|
|
615
669
|
{dots}
|
|
616
670
|
{/* The one under the pointer, on top */}
|
|
617
671
|
{hp && (
|
|
@@ -12,7 +12,7 @@ function useIsMobile() {
|
|
|
12
12
|
const TIER_COLORS = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
|
|
13
13
|
|
|
14
14
|
|
|
15
|
-
export default function FilterPanel({ collapsed, onToggle, mode, filter, onFilterChange, visualMode, onVisualModeChange, tierCounts, bridgeTierCounts }) {
|
|
15
|
+
export default function FilterPanel({ collapsed, onToggle, mode, filter, onFilterChange, visualMode, onVisualModeChange, tierCounts, bridgeTierCounts, degrees = [1], onDegreesChange, degreeCounts = {} }) {
|
|
16
16
|
const isMobile = useIsMobile();
|
|
17
17
|
const isDegreesMode = mode === 'degrees';
|
|
18
18
|
// Same source of truth the renderer uses, so the menu can never offer a
|
|
@@ -105,6 +105,42 @@ export default function FilterPanel({ collapsed, onToggle, mode, filter, onFilte
|
|
|
105
105
|
</div>
|
|
106
106
|
|
|
107
107
|
{/* Tier Filters */}
|
|
108
|
+
{/* Network Circle: which degrees to draw. Any mix, never none: 2nd and
|
|
109
|
+
3rd without your own connections shows who's valuable outside them. */}
|
|
110
|
+
{!isDegreesMode && onDegreesChange && (
|
|
111
|
+
<div style={{ marginBottom: 18 }}>
|
|
112
|
+
<div style={{ fontSize: 9, fontWeight: 700, color: '#555', letterSpacing: 1, marginBottom: 8, textTransform: 'uppercase' }}>
|
|
113
|
+
Degree
|
|
114
|
+
</div>
|
|
115
|
+
<div style={{ display: 'flex', gap: 6 }}>
|
|
116
|
+
{[[1, '1st', 'Your connections'], [2, '2nd', 'In a scanned circle'], [3, '3rd', 'Found by a company scan']].map(([d, label, what]) => {
|
|
117
|
+
const on = degrees.includes(d);
|
|
118
|
+
const count = degreeCounts[d] || 0;
|
|
119
|
+
const last = on && degrees.length === 1;
|
|
120
|
+
return (
|
|
121
|
+
<button
|
|
122
|
+
key={d}
|
|
123
|
+
title={`${what}: ${count.toLocaleString()}${last ? ' (keep at least one)' : ''}`}
|
|
124
|
+
aria-pressed={on}
|
|
125
|
+
disabled={last || (!on && !count)}
|
|
126
|
+
onClick={() => onDegreesChange(on ? degrees.filter((x) => x !== d) : [...degrees, d].sort())}
|
|
127
|
+
style={{
|
|
128
|
+
flex: 1, padding: '7px 4px', borderRadius: 8, fontSize: 11.5, fontWeight: 700,
|
|
129
|
+
cursor: last || (!on && !count) ? 'default' : 'pointer',
|
|
130
|
+
border: on ? '1px solid rgba(52,152,219,0.6)' : '1px solid rgba(255,255,255,0.08)',
|
|
131
|
+
background: on ? 'rgba(52,152,219,0.18)' : 'rgba(255,255,255,0.03)',
|
|
132
|
+
color: on ? '#cfe6f7' : count ? '#888' : '#444',
|
|
133
|
+
}}
|
|
134
|
+
>
|
|
135
|
+
{label}
|
|
136
|
+
<div style={{ fontSize: 9.5, fontWeight: 500, opacity: 0.8, marginTop: 2 }}>{count.toLocaleString()}</div>
|
|
137
|
+
</button>
|
|
138
|
+
);
|
|
139
|
+
})}
|
|
140
|
+
</div>
|
|
141
|
+
</div>
|
|
142
|
+
)}
|
|
143
|
+
|
|
108
144
|
<div style={{ marginBottom: 18 }}>
|
|
109
145
|
{/* In Degrees these chips filter by the tier of the connection who
|
|
110
146
|
introduces you, not the person reached — Separation has its own
|
|
@@ -207,7 +207,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
207
207
|
};
|
|
208
208
|
|
|
209
209
|
const nodes = [centerNode, ...connections.map(c => ({
|
|
210
|
-
id: c.id, name: c.name, tier: c.tier, degree: 1,
|
|
210
|
+
id: c.id, name: c.name, tier: c.tier, degree: c.degree || 1, source_connection_id: c.source_connection_id,
|
|
211
211
|
power_score: parseFloat(c.power_score) || 1,
|
|
212
212
|
company: c.company, role: c.role, headline: c.headline,
|
|
213
213
|
profile_url: c.profile_url, profile_image_url: localPhoto(c.profile_image_url),
|
|
@@ -219,7 +219,14 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
219
219
|
}))];
|
|
220
220
|
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
|
221
221
|
|
|
222
|
-
|
|
222
|
+
// Your connections hang off you; someone further out (Filter → Degree) off the
|
|
223
|
+
// connection whose circle they're in, when that one is drawn too, so they
|
|
224
|
+
// gather near them.
|
|
225
|
+
const drawnD1 = new Set(connections.filter(c => (c.degree || 1) === 1).map(c => c.id));
|
|
226
|
+
const links = connections.map(c => {
|
|
227
|
+
const via = (c.degree || 1) > 1 && drawnD1.has(c.source_connection_id) ? c.source_connection_id : CENTER_ID;
|
|
228
|
+
return { source: via, target: c.id, near: via !== CENTER_ID };
|
|
229
|
+
});
|
|
223
230
|
|
|
224
231
|
const tierRadius = (tier) => {
|
|
225
232
|
switch (tier) { case 'S': return 150; case 'A': return 250; case 'B': return 350; case 'C': return 450; default: return 520; }
|
|
@@ -227,7 +234,8 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
227
234
|
|
|
228
235
|
const nodeRadius = (d) => {
|
|
229
236
|
if (d.id === CENTER_ID) return 18;
|
|
230
|
-
|
|
237
|
+
const r = Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
|
|
238
|
+
return d.degree > 1 ? Math.max(2.5, r * 0.75) : r; // further out, a little smaller
|
|
231
239
|
};
|
|
232
240
|
|
|
233
241
|
// A phone. Judged by the window, as the page judges it, not by the graph's
|
|
@@ -235,6 +243,25 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
235
243
|
const isMobileGraph = window.innerWidth < 768;
|
|
236
244
|
|
|
237
245
|
const g = svg.append('g');
|
|
246
|
+
|
|
247
|
+
// Faint S–D guide rings where each tier settles (the radial force below), so
|
|
248
|
+
// the tier bands read at a glance. Not on a phone, whose layout has no rings.
|
|
249
|
+
// Once the layout settles they move to where each tier's dots actually sit
|
|
250
|
+
// (the median distance out), which the other forces push past that radius.
|
|
251
|
+
const guideRings = new Map();
|
|
252
|
+
if (!isMobileGraph) {
|
|
253
|
+
const guides = g.append('g').attr('class', 'tier-guides').style('pointer-events', 'none');
|
|
254
|
+
for (const tier of ['S', 'A', 'B', 'C', 'D']) {
|
|
255
|
+
const r = tierRadius(tier);
|
|
256
|
+
const ring = guides.append('circle').attr('r', r).attr('fill', 'none')
|
|
257
|
+
.attr('stroke', tierColors[tier] || '#666').attr('stroke-opacity', 0.16).attr('stroke-width', 1)
|
|
258
|
+
.attr('stroke-dasharray', '2 4');
|
|
259
|
+
const label = guides.append('text').attr('x', 0).attr('y', -r - 4).attr('text-anchor', 'middle')
|
|
260
|
+
.attr('font-size', 10).attr('font-weight', 700).attr('fill', tierColors[tier] || '#666').attr('fill-opacity', 0.5)
|
|
261
|
+
.text(tier);
|
|
262
|
+
guideRings.set(tier, { ring, label });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
238
265
|
// Where the view is going while a transition takes it there. A resize part-way
|
|
239
266
|
// through re-fits the destination, and a rebuild starts from it, rather than
|
|
240
267
|
// from a point along the way.
|
|
@@ -330,11 +357,20 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
330
357
|
}
|
|
331
358
|
|
|
332
359
|
const simulation = d3.forceSimulation(nodes)
|
|
333
|
-
.force('link', d3.forceLink(links).id(d => d.id).distance(d => tierRadius(d.target.tier || 'D')).strength(isMobileGraph ? 0 : 0.1))
|
|
360
|
+
.force('link', d3.forceLink(links).id(d => d.id).distance(d => (d.near ? 45 : tierRadius(d.target.tier || 'D'))).strength(isMobileGraph ? 0 : 0.1))
|
|
334
361
|
.force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
|
|
335
362
|
.force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
|
|
336
363
|
.force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
|
|
337
364
|
.force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier), 0, 0).strength(0.3));
|
|
365
|
+
simulation.on('end.guides', () => {
|
|
366
|
+
for (const [tier, { ring, label }] of guideRings) {
|
|
367
|
+
const out = nodes.filter((n) => n.tier === tier && n.id !== CENTER_ID).map((n) => Math.hypot(n.x || 0, n.y || 0)).sort((x, y) => x - y);
|
|
368
|
+
if (!out.length) { ring.attr('opacity', 0); label.attr('opacity', 0); continue; }
|
|
369
|
+
const r = out[Math.floor(out.length / 2)];
|
|
370
|
+
ring.attr('r', r).attr('opacity', 1);
|
|
371
|
+
label.attr('y', -r - 4).attr('opacity', 1);
|
|
372
|
+
}
|
|
373
|
+
});
|
|
338
374
|
|
|
339
375
|
// forceLink has already swapped each link's ids for the nodes themselves, so
|
|
340
376
|
// the colour is one step away. It used to search every node for every link:
|
|
@@ -364,6 +400,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
364
400
|
const node = g.append('g').selectAll('circle').data(nodes).join('circle')
|
|
365
401
|
.attr('r', nodeRadius)
|
|
366
402
|
.attr('fill', d => d.id === CENTER_ID ? '#fff' : tierColors[d.tier] || '#666')
|
|
403
|
+
.attr('fill-opacity', d => (d.degree >= 3 ? 0.55 : d.degree === 2 ? 0.75 : 1))
|
|
367
404
|
.attr('stroke', d => {
|
|
368
405
|
if (d.id === CENTER_ID) return '#FFD700';
|
|
369
406
|
if (d.is_catalyst) return '#00ff88';
|
|
@@ -425,7 +462,9 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
425
462
|
setupDrag(node, simulation, CENTER_ID);
|
|
426
463
|
|
|
427
464
|
const labels = g.append('g').selectAll('text')
|
|
428
|
-
|
|
465
|
+
// Names for you, your S-tier connections and catalysts; further out (the
|
|
466
|
+
// Degree filter) only on hover, or hundreds of S-tier names pile up.
|
|
467
|
+
.data(nodes.filter(n => n.id === CENTER_ID || (n.degree === 1 && (n.tier === 'S' || n.is_catalyst))))
|
|
429
468
|
.join('text')
|
|
430
469
|
.text(d => d.is_catalyst && d.tier !== 'S' ? d.name + ' ⚡' : d.name)
|
|
431
470
|
.attr('font-size', d => d.id === CENTER_ID ? 14 : 10)
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
// files the scanner writes.
|
|
8
8
|
|
|
9
9
|
import { useState } from 'react';
|
|
10
|
+
import { limitQuestion, limitNote, SAFE_LIMITS } from '../../lib/search-risk';
|
|
10
11
|
|
|
11
12
|
const LINE = '1px solid rgba(255,255,255,0.1)';
|
|
12
13
|
const TIER = { S: '#FFD700', A: '#9B59B6', B: '#3498DB', C: '#95A5A6', D: '#BDC3C7' };
|
|
@@ -59,6 +60,13 @@ export function BudgetBox({ li, onSetLimits, disabled }) {
|
|
|
59
60
|
const today = li.unreadable ? '?' : li.searchesToday;
|
|
60
61
|
const views = li.unreadable ? '?' : li.profilesToday;
|
|
61
62
|
const set = (change) => onSetLimits({ ...limits, ...change });
|
|
63
|
+
// A budget past what LinkedIn has put up with is asked about first (lib/search-risk.js).
|
|
64
|
+
const ask = (change) => {
|
|
65
|
+
const q = limitQuestion(limits, { ...limits, ...change });
|
|
66
|
+
if (q && !window.confirm(q)) return;
|
|
67
|
+
set(change);
|
|
68
|
+
};
|
|
69
|
+
const note = limitNote(limits);
|
|
62
70
|
return (
|
|
63
71
|
<div style={{ padding: '12px 14px', borderRadius: 8, border: LINE, background: 'rgba(255,255,255,0.03)', fontSize: 12.5, color: '#b8c4c4' }}>
|
|
64
72
|
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(210px, 1fr))', gap: 14 }}>
|
|
@@ -77,10 +85,10 @@ export function BudgetBox({ li, onSetLimits, disabled }) {
|
|
|
77
85
|
</div>
|
|
78
86
|
<div style={{ display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap', marginTop: 10 }}>
|
|
79
87
|
<span>Budget:</span>
|
|
80
|
-
<select value={limits.daily} disabled={disabled} onChange={(e) =>
|
|
88
|
+
<select value={limits.daily} disabled={disabled} onChange={(e) => ask({ daily: Number(e.target.value) })} style={sel}>
|
|
81
89
|
{DAILY.map((n) => <option key={n} value={n}>{n} a day</option>)}
|
|
82
90
|
</select>
|
|
83
|
-
<select value={limits.monthly} disabled={disabled} onChange={(e) =>
|
|
91
|
+
<select value={limits.monthly} disabled={disabled} onChange={(e) => ask({ monthly: Number(e.target.value) })} style={sel}>
|
|
84
92
|
{MONTHLY.map((n) => <option key={n} value={n}>{n ? `${n} a month` : 'no monthly cap (Premium)'}</option>)}
|
|
85
93
|
</select>
|
|
86
94
|
<select value={limits.profiles} disabled={disabled} onChange={(e) => set({ profiles: Number(e.target.value) })} style={sel}>
|
|
@@ -91,8 +99,17 @@ export function BudgetBox({ li, onSetLimits, disabled }) {
|
|
|
91
99
|
Every page of someone’s connections is one search. LinkedIn limits a free account’s people
|
|
92
100
|
searches by the month (it doesn’t say how many; reports put it around 250–350), resetting on the 1st.
|
|
93
101
|
When a budget is used, a scan saves what it read and stops; the next one carries on from the same page.
|
|
94
|
-
{limits.daily > 100 && <b style={{ color: '#FFD700' }}> {limits.daily} a day can use up a free account’s month in a day or two.</b>}
|
|
95
102
|
</div>
|
|
103
|
+
{note && (
|
|
104
|
+
<div style={{ marginTop: 8, display: 'flex', gap: 10, alignItems: 'center', flexWrap: 'wrap', color: '#FFD700', lineHeight: 1.6 }}>
|
|
105
|
+
<b style={{ flex: 1, minWidth: 200 }}>{note}</b>
|
|
106
|
+
<button
|
|
107
|
+
onClick={() => set(SAFE_LIMITS)}
|
|
108
|
+
disabled={disabled}
|
|
109
|
+
style={{ ...sel, cursor: disabled ? 'not-allowed' : 'pointer', color: '#FFD700', borderColor: 'rgba(255,215,0,0.4)' }}
|
|
110
|
+
>Back to {SAFE_LIMITS.daily} a day, {SAFE_LIMITS.monthly} a month</button>
|
|
111
|
+
</div>
|
|
112
|
+
)}
|
|
96
113
|
<div style={{ marginTop: 6, color: '#778', lineHeight: 1.6 }}>
|
|
97
114
|
Each circle scan opens the person’s profile once, which is one profile view. Profile views are what
|
|
98
115
|
LinkedIn restricted an account for, after about 20 in an hour. The scanner opens at most one a minute, and
|
|
@@ -11,6 +11,7 @@ import { useUser } from './UserProvider';
|
|
|
11
11
|
import { routeIndex, routesFor } from '../../lib/separation';
|
|
12
12
|
import { topCompanies } from '../../lib/scoring';
|
|
13
13
|
import { reachIndex, reachState, circleState, readyToScan, circleScanCost } from '../../lib/reach';
|
|
14
|
+
import { exclusiveReach, bridgeOverlap } from '../../lib/brokerage';
|
|
14
15
|
import Avatar from './Avatar';
|
|
15
16
|
import { localPhoto } from '../../lib/photos';
|
|
16
17
|
|
|
@@ -39,6 +40,9 @@ export default function Sidebar({ selected, stats, tierColors, connections, degr
|
|
|
39
40
|
// shows even while a tier chip is narrowing the view, and a person picked in
|
|
40
41
|
// Chains or Galaxy gets every route too.
|
|
41
42
|
const routeIdx = useMemo(() => routeIndex(degree2), [degree2]);
|
|
43
|
+
// Who only each connection reaches (lib/brokerage.js), for their card.
|
|
44
|
+
const exclusive = useMemo(() => exclusiveReach(degree2, connections), [degree2, connections]);
|
|
45
|
+
const overlap = useMemo(() => bridgeOverlap(degree2, connections), [degree2, connections]);
|
|
42
46
|
const bridgeById = useMemo(() => new Map(connections.map(c => [c.id, c])), [connections]);
|
|
43
47
|
// Who you've asked, shared with every view: a request sent here shows in
|
|
44
48
|
// Separation, the circle and the Outlink queue at once (lib/requests-client.js).
|
|
@@ -139,6 +143,39 @@ export default function Sidebar({ selected, stats, tierColors, connections, degr
|
|
|
139
143
|
/>
|
|
140
144
|
)}
|
|
141
145
|
|
|
146
|
+
{selected.degree !== 2 && (exclusive.get(selected.id)?.only > 0 || (overlap.get(selected.id)?.share >= 0.15 && overlap.get(selected.id)?.shared >= 3)) && (() => {
|
|
147
|
+
const ex = exclusive.get(selected.id);
|
|
148
|
+
const ov = overlap.get(selected.id);
|
|
149
|
+
// A twin only when it's real: at least 15% of either's people, and 3 of them.
|
|
150
|
+
const twin = ov && ov.share >= 0.15 && ov.shared >= 3 && connections.find((c) => c.id === ov.with);
|
|
151
|
+
const first = selected.name?.split(' ')[0] || 'them';
|
|
152
|
+
return (
|
|
153
|
+
<div style={{
|
|
154
|
+
background: 'rgba(0,255,136,0.05)', border: '1px solid rgba(0,255,136,0.2)',
|
|
155
|
+
borderRadius: 8, padding: 12, marginBottom: 16,
|
|
156
|
+
}}>
|
|
157
|
+
<div style={{ fontSize: 10, color: '#00ff88', fontWeight: 700, marginBottom: 4, letterSpacing: 1 }}>
|
|
158
|
+
{ex?.only > 0 ? `ONLY THROUGH ${first.toUpperCase()}` : `${first.toUpperCase()}’S CIRCLE`}
|
|
159
|
+
</div>
|
|
160
|
+
{ex?.only > 0 && (
|
|
161
|
+
<div style={{ fontSize: 12, color: '#ccc', lineHeight: 1.5 }}>
|
|
162
|
+
<strong style={{ color: '#00ff88' }}>{ex.only.toLocaleString()}</strong> of the {ex.total.toLocaleString()} people in {first}’s
|
|
163
|
+
circle are reached by none of your other connections. {first} is your only way to them.
|
|
164
|
+
</div>
|
|
165
|
+
)}
|
|
166
|
+
{twin && (
|
|
167
|
+
<div style={{ fontSize: 12, color: '#aab', lineHeight: 1.5, marginTop: ex?.only > 0 ? 6 : 0 }}>
|
|
168
|
+
Opens the same doors as{' '}
|
|
169
|
+
<button onClick={() => onSelect?.(twin)} style={{ background: 'none', border: 'none', padding: 0, color: '#fff', fontWeight: 700, cursor: 'pointer', fontSize: 12 }}>
|
|
170
|
+
{twin.name}
|
|
171
|
+
</button>
|
|
172
|
+
: {Math.round(ov.share * 100)}% of the people either reaches, both do ({ov.shared.toLocaleString()}).
|
|
173
|
+
</div>
|
|
174
|
+
)}
|
|
175
|
+
</div>
|
|
176
|
+
);
|
|
177
|
+
})()}
|
|
178
|
+
|
|
142
179
|
{selected.is_catalyst && (
|
|
143
180
|
<div style={{
|
|
144
181
|
background: 'rgba(0,255,136,0.08)', border: '1px solid rgba(0,255,136,0.3)',
|
|
@@ -27,7 +27,7 @@ import SeparationView from './SeparationView';
|
|
|
27
27
|
*/
|
|
28
28
|
export const VIEWS = {
|
|
29
29
|
galaxy: { component: ForceGraph, modes: ['network'], label: 'Galaxy', icon: '🌌', desc: 'Force-directed layout' },
|
|
30
|
-
orbit: { component: OrbitGraph, modes: ['
|
|
30
|
+
orbit: { component: OrbitGraph, modes: ['degrees'], label: 'Orbit', icon: '🪐', desc: 'Tier orbits + circle dots; in Degrees, every mapped circle fanned out', allDegree2: true },
|
|
31
31
|
separation: { component: SeparationView, modes: ['degrees'], label: 'Separation', icon: '🏆', desc: 'Every 2nd degree, ranked · every way in' },
|
|
32
32
|
chain: { component: ChainView, modes: ['degrees'], label: 'Bridge Chains', icon: '🔗', desc: 'Degree paths' },
|
|
33
33
|
rings: { component: RingsView, modes: ['network', 'degrees'], label: 'Pyramid', icon: '🔺', desc: 'Tier hierarchy' },
|
|
@@ -36,11 +36,13 @@ export const VIEWS = {
|
|
|
36
36
|
};
|
|
37
37
|
|
|
38
38
|
/**
|
|
39
|
-
* The order they appear in the menu.
|
|
40
|
-
* also where resolveView lands when the chosen view isn't a Degrees one
|
|
41
|
-
* (Galaxy, say, carried over from Network Circle).
|
|
39
|
+
* The order they appear in the menu. Bridge Chains is the first Degrees view, so
|
|
40
|
+
* it is also where resolveView lands when the chosen view isn't a Degrees one
|
|
41
|
+
* (Galaxy, say, carried over from Network Circle); then Separation, then Orbit.
|
|
42
|
+
* Orbit is a Degrees view only: in Network Circle it was slow and said nothing
|
|
43
|
+
* the Galaxy doesn't (OrbitGraph still has that branch until Orbit is redrawn).
|
|
42
44
|
*/
|
|
43
|
-
const ORDER = ['galaxy', '
|
|
45
|
+
const ORDER = ['galaxy', 'chain', 'separation', 'orbit', 'rings', 'list'];
|
|
44
46
|
|
|
45
47
|
export function viewsForMode(mode) {
|
|
46
48
|
return ORDER
|
|
Binary file
|
|
@@ -8,6 +8,7 @@ import useRequests from './components/useRequests';
|
|
|
8
8
|
import { requestCount } from '../lib/requests-client';
|
|
9
9
|
import { loadNetwork } from '../lib/network';
|
|
10
10
|
import { VIEWS, resolveView } from './components/views';
|
|
11
|
+
import { peopleByDegree, tierCountsOf } from '../lib/degrees';
|
|
11
12
|
import Sidebar from './components/Sidebar';
|
|
12
13
|
import FilterPanel from './components/FilterPanel';
|
|
13
14
|
import OnboardingGate from './components/OnboardingGate';
|
|
@@ -68,6 +69,10 @@ function HomeInner() {
|
|
|
68
69
|
const isMobile = useIsMobile();
|
|
69
70
|
const [degree1, setDegree1] = useState([]);
|
|
70
71
|
const [degree2, setDegree2] = useState([]);
|
|
72
|
+
// People found only by company scans: 3rd degree in Network Circle's filter.
|
|
73
|
+
const [degree3, setDegree3] = useState([]);
|
|
74
|
+
// Which degrees Network Circle draws: your connections alone until you pick more.
|
|
75
|
+
const [degrees, setDegrees] = useState([1]);
|
|
71
76
|
const [selected, setSelected] = useState(null);
|
|
72
77
|
const focusNodeRef = useRef(null);
|
|
73
78
|
const [filter, setFilter] = useState('all');
|
|
@@ -102,7 +107,7 @@ function HomeInner() {
|
|
|
102
107
|
// imported in this tab (1st-degree only, never persisted), or Supabase.
|
|
103
108
|
const csv = hasCsvNetwork() ? loadCsvNetwork() : null;
|
|
104
109
|
if (csv) { setCsvMode(true); setCsvSource(csv.source || 'csv'); }
|
|
105
|
-
const { degree1: d1, degree2: d2 } = IS_DEMO
|
|
110
|
+
const { degree1: d1, degree2: d2, degree3: d3 = [] } = IS_DEMO
|
|
106
111
|
? await loadDemoNetwork()
|
|
107
112
|
: csv
|
|
108
113
|
? csv
|
|
@@ -112,6 +117,7 @@ function HomeInner() {
|
|
|
112
117
|
|
|
113
118
|
setDegree1(d1);
|
|
114
119
|
setDegree2(d2);
|
|
120
|
+
setDegree3(d3 || []);
|
|
115
121
|
shapeRef.current = networkShape(d1, d2);
|
|
116
122
|
setStats(statsFor(d1, d2));
|
|
117
123
|
setLoading(false);
|
|
@@ -131,8 +137,9 @@ function HomeInner() {
|
|
|
131
137
|
const reload = useCallback(async () => {
|
|
132
138
|
if (IS_DEMO || hasCsvNetwork() || !userId) return;
|
|
133
139
|
try {
|
|
134
|
-
const [{ degree1: d1, degree2: d2 }, notes] = await Promise.all([loadNetwork(userId), loadScanNotes()]);
|
|
140
|
+
const [{ degree1: d1, degree2: d2, degree3: d3 = [] }, notes] = await Promise.all([loadNetwork(userId), loadScanNotes()]);
|
|
135
141
|
setScanNotes((prev) => (JSON.stringify(prev) === JSON.stringify(notes) ? prev : notes));
|
|
142
|
+
setDegree3((prev) => (prev.length === (d3 || []).length ? prev : d3 || []));
|
|
136
143
|
const shape = networkShape(d1, d2);
|
|
137
144
|
if (shape === shapeRef.current) return;
|
|
138
145
|
shapeRef.current = shape;
|
|
@@ -156,9 +163,16 @@ function HomeInner() {
|
|
|
156
163
|
// scene when these change identity: computed inline, every re-render — a click,
|
|
157
164
|
// the sidebar opening, notifications arriving — reset the galaxy's layout. The
|
|
158
165
|
// empty list in network mode was a new [] each time, which was enough on its own.
|
|
166
|
+
// Network Circle draws the degrees picked in the Filter panel, each person
|
|
167
|
+
// once, at the nearest degree they're found (lib/degrees.js).
|
|
168
|
+
const byDegree = useMemo(() => peopleByDegree(degree1, degree2, degree3), [degree1, degree2, degree3]);
|
|
169
|
+
const networkRows = useMemo(
|
|
170
|
+
() => (isDegreesMode ? connections : degrees.flatMap((d) => byDegree[d] || [])),
|
|
171
|
+
[isDegreesMode, connections, degrees, byDegree],
|
|
172
|
+
);
|
|
159
173
|
const filtered = useMemo(
|
|
160
|
-
() => (filter === 'all' ?
|
|
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
|