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
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
'use client';
|
|
2
2
|
|
|
3
|
-
import { useEffect, useRef, useState } from 'react';
|
|
3
|
+
import { useEffect, useMemo, useRef, useState } from 'react';
|
|
4
4
|
import * as d3 from 'd3';
|
|
5
5
|
import { localPhoto } from '../../lib/photos';
|
|
6
6
|
import { recentre } from '../../lib/galaxy';
|
|
7
|
+
import { reachIndex, readyByCircle, scanBars } from '../../lib/reach';
|
|
8
|
+
import { ringSegments, RING } from '../../lib/dot-rings';
|
|
7
9
|
|
|
8
10
|
// Connection fields are attacker-reachable: /api/ingest and /api/update-images
|
|
9
11
|
// accept writes, and a page on any other site can POST to this app on localhost.
|
|
@@ -57,7 +59,21 @@ const RING_CSS = `
|
|
|
57
59
|
.galaxy-ring-pulse { animation: none; opacity: 0.8; }
|
|
58
60
|
}`;
|
|
59
61
|
|
|
60
|
-
export default function ForceGraph({ connections, onSelect, tierColors, focusNodeRef, userName, selectedId = null }) {
|
|
62
|
+
export default function ForceGraph({ connections, onSelect, tierColors, focusNodeRef, userName, selectedId = null, scanNotes, fullDegree1, fullDegree2 }) {
|
|
63
|
+
// The ring round a connection's dot (design C): how much of their circle is
|
|
64
|
+
// scanned, and how many in it are ready to scan (lib/reach.js).
|
|
65
|
+
const marks = useMemo(() => {
|
|
66
|
+
const reach = reachIndex(fullDegree1 || connections, fullDegree2 || [], scanNotes || {});
|
|
67
|
+
const ready = readyByCircle(reach);
|
|
68
|
+
const out = new Map();
|
|
69
|
+
for (const c of connections) {
|
|
70
|
+
if ((c.degree || 1) !== 1) continue;
|
|
71
|
+
const bars = scanBars(c, reach);
|
|
72
|
+
const r = ready.get(c.id) || 0;
|
|
73
|
+
if (bars != null || r) out.set(c.id, { bars, ready: r });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}, [connections, fullDegree1, fullDegree2, scanNotes]);
|
|
61
77
|
const svgRef = useRef(null);
|
|
62
78
|
const ringRef = useRef(null);
|
|
63
79
|
const sceneRef = useRef(null); // The scene on screen: { resize, select }
|
|
@@ -123,7 +139,7 @@ export default function ForceGraph({ connections, onSelect, tierColors, focusNod
|
|
|
123
139
|
const saved = transform ? recentre(transform, setFor, size) : null;
|
|
124
140
|
|
|
125
141
|
const select = (d) => onSelectRef.current?.(d);
|
|
126
|
-
const scene = renderNetworkMode(svg, ringRef.current, size, connections, select, tierColors, focusNodeRef, saved, viewRef, userName);
|
|
142
|
+
const scene = renderNetworkMode(svg, ringRef.current, size, connections, select, tierColors, focusNodeRef, saved, viewRef, userName, marks);
|
|
127
143
|
sceneRef.current = scene;
|
|
128
144
|
scene.select(selectedIdRef.current);
|
|
129
145
|
|
|
@@ -145,7 +161,7 @@ export default function ForceGraph({ connections, onSelect, tierColors, focusNod
|
|
|
145
161
|
// why onSelect is not a dependency. The size is read through sizeRef: a new
|
|
146
162
|
// size re-fits the view rather than rebuilding.
|
|
147
163
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
148
|
-
}, [connections, tierColors, measured]);
|
|
164
|
+
}, [connections, tierColors, measured, marks]);
|
|
149
165
|
|
|
150
166
|
return (
|
|
151
167
|
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
|
@@ -200,14 +216,15 @@ function easeRadius(el, r) {
|
|
|
200
216
|
// Graph coordinates put you at 0,0, whatever the size of the box; the zoom
|
|
201
217
|
// transform places that in the box. So a new size only moves the view, and a
|
|
202
218
|
// rebuild can start from the view as it was.
|
|
203
|
-
function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, focusNodeRef, savedTransform, viewRef, userName) {
|
|
219
|
+
function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, focusNodeRef, savedTransform, viewRef, userName, marks = new Map()) {
|
|
204
220
|
const centerNode = {
|
|
205
221
|
id: CENTER_ID, name: userName || 'You', tier: 'center', degree: 0,
|
|
206
222
|
power_score: 10, fx: 0, fy: 0,
|
|
207
223
|
};
|
|
208
224
|
|
|
209
225
|
const nodes = [centerNode, ...connections.map(c => ({
|
|
210
|
-
id: c.id, name: c.name, tier: c.tier, degree: 1,
|
|
226
|
+
id: c.id, name: c.name, tier: c.tier, degree: c.degree || 1, source_connection_id: c.source_connection_id,
|
|
227
|
+
unlocked_from_bridge_id: c.unlocked_from_bridge_id,
|
|
211
228
|
power_score: parseFloat(c.power_score) || 1,
|
|
212
229
|
company: c.company, role: c.role, headline: c.headline,
|
|
213
230
|
profile_url: c.profile_url, profile_image_url: localPhoto(c.profile_image_url),
|
|
@@ -219,15 +236,59 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
219
236
|
}))];
|
|
220
237
|
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
|
221
238
|
|
|
222
|
-
|
|
239
|
+
// Who hangs off whom. Your connections hang off you; someone in a circle off
|
|
240
|
+
// the connection whose circle it is, when that one is drawn too; and with
|
|
241
|
+
// more than your connections drawn (Filter → Degree), someone you added
|
|
242
|
+
// through a circle off the one they came from, so a chain reads as a chain.
|
|
243
|
+
const drawnD1 = new Set(connections.filter(c => (c.degree || 1) === 1).map(c => c.id));
|
|
244
|
+
const multi = connections.some(c => (c.degree || 1) > 1);
|
|
245
|
+
const parentOf = new Map();
|
|
246
|
+
for (const n of nodes) {
|
|
247
|
+
if (n.id === CENTER_ID) continue;
|
|
248
|
+
const from = n.unlocked_from_bridge_id;
|
|
249
|
+
if (n.degree === 1 && multi && from != null && from !== n.id && drawnD1.has(from)) parentOf.set(n.id, from);
|
|
250
|
+
else if (n.degree === 2 && drawnD1.has(n.source_connection_id)) parentOf.set(n.id, n.source_connection_id);
|
|
251
|
+
}
|
|
252
|
+
// How far along the chain: 1 for your connections, one more past each person
|
|
253
|
+
// you came through, to 6.
|
|
254
|
+
const bandMemo = new Map();
|
|
255
|
+
const bandOf = (n, seen = new Set()) => {
|
|
256
|
+
if (n.id === CENTER_ID) return 0;
|
|
257
|
+
if (bandMemo.has(n.id)) return bandMemo.get(n.id);
|
|
258
|
+
const p = parentOf.get(n.id);
|
|
259
|
+
let b = n.degree >= 3 ? 3 : n.degree;
|
|
260
|
+
if (p != null && !seen.has(n.id)) { seen.add(n.id); b = bandOf(nodeById.get(p), seen) + 1; }
|
|
261
|
+
b = Math.min(b, 6);
|
|
262
|
+
bandMemo.set(n.id, b);
|
|
263
|
+
return b;
|
|
264
|
+
};
|
|
265
|
+
const links = connections.map(c => {
|
|
266
|
+
const via = parentOf.get(c.id) ?? CENTER_ID;
|
|
267
|
+
return { source: via, target: c.id, near: via !== CENTER_ID };
|
|
268
|
+
});
|
|
223
269
|
|
|
224
270
|
const tierRadius = (tier) => {
|
|
225
271
|
switch (tier) { case 'S': return 150; case 'A': return 250; case 'B': return 350; case 'C': return 450; default: return 520; }
|
|
226
272
|
};
|
|
273
|
+
// More than one band drawn: a band for each step along the chain, the first
|
|
274
|
+
// (your connections) a tighter ring, each sorted by tier with S nearest, close
|
|
275
|
+
// enough to read as layers and far enough apart not to clump. One band alone
|
|
276
|
+
// keeps the roomier tier rings.
|
|
277
|
+
const TIERS = ['S', 'A', 'B', 'C', 'D'];
|
|
278
|
+
const SLOTS = [[115, 135, 155, 175, 192], [265, 300, 335, 370, 398], [465, 500, 535, 565, 590], [660, 690, 720, 745, 765]];
|
|
279
|
+
const bands = [...new Set(nodes.filter(n => n.id !== CENTER_ID).map(n => bandOf(n)))].sort((a, b) => a - b);
|
|
280
|
+
const layered = multi && bands.length > 1;
|
|
281
|
+
const radiusOf = (d) => {
|
|
282
|
+
if (!layered) return tierRadius(d.tier);
|
|
283
|
+
const slot = Math.min(bands.indexOf(bandOf(d)), SLOTS.length - 1);
|
|
284
|
+
const t = TIERS.indexOf(d.tier);
|
|
285
|
+
return SLOTS[slot][t < 0 ? 4 : t];
|
|
286
|
+
};
|
|
227
287
|
|
|
228
288
|
const nodeRadius = (d) => {
|
|
229
289
|
if (d.id === CENTER_ID) return 18;
|
|
230
|
-
|
|
290
|
+
const r = Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
|
|
291
|
+
return d.degree > 1 ? Math.max(2.5, r * 0.75) : r; // further out, a little smaller
|
|
231
292
|
};
|
|
232
293
|
|
|
233
294
|
// A phone. Judged by the window, as the page judges it, not by the graph's
|
|
@@ -235,6 +296,37 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
235
296
|
const isMobileGraph = window.innerWidth < 768;
|
|
236
297
|
|
|
237
298
|
const g = svg.append('g');
|
|
299
|
+
|
|
300
|
+
// Faint S–D guide rings where each tier settles (the radial force below), so
|
|
301
|
+
// the tier bands read at a glance. Not on a phone, whose layout has no rings.
|
|
302
|
+
// Once the layout settles they move to where each tier's dots actually sit
|
|
303
|
+
// (the median distance out), which the other forces push past that radius.
|
|
304
|
+
const guideRings = new Map();
|
|
305
|
+
if (!isMobileGraph && layered) {
|
|
306
|
+
// In bands: a faint ring and a label for each step along the chain instead.
|
|
307
|
+
const guides = g.append('g').attr('class', 'degree-guides').style('pointer-events', 'none');
|
|
308
|
+
const names = ['', '1st', '2nd', '3rd', '4th', '5th', '6th'];
|
|
309
|
+
bands.slice(0, SLOTS.length).forEach((b, slot) => {
|
|
310
|
+
const r = SLOTS[slot][2];
|
|
311
|
+
guides.append('circle').attr('r', r).attr('fill', 'none').attr('stroke', '#8899aa')
|
|
312
|
+
.attr('stroke-opacity', 0.12).attr('stroke-width', 1).attr('stroke-dasharray', '2 4');
|
|
313
|
+
guides.append('text').attr('x', 0).attr('y', -SLOTS[slot][4] - 8).attr('text-anchor', 'middle')
|
|
314
|
+
.attr('font-size', 10).attr('font-weight', 700).attr('fill', '#8899aa').attr('fill-opacity', 0.55)
|
|
315
|
+
.text(names[b] || `${b}th`);
|
|
316
|
+
});
|
|
317
|
+
} else if (!isMobileGraph) {
|
|
318
|
+
const guides = g.append('g').attr('class', 'tier-guides').style('pointer-events', 'none');
|
|
319
|
+
for (const tier of ['S', 'A', 'B', 'C', 'D']) {
|
|
320
|
+
const r = tierRadius(tier);
|
|
321
|
+
const ring = guides.append('circle').attr('r', r).attr('fill', 'none')
|
|
322
|
+
.attr('stroke', tierColors[tier] || '#666').attr('stroke-opacity', 0.16).attr('stroke-width', 1)
|
|
323
|
+
.attr('stroke-dasharray', '2 4');
|
|
324
|
+
const label = guides.append('text').attr('x', 0).attr('y', -r - 4).attr('text-anchor', 'middle')
|
|
325
|
+
.attr('font-size', 10).attr('font-weight', 700).attr('fill', tierColors[tier] || '#666').attr('fill-opacity', 0.5)
|
|
326
|
+
.text(tier);
|
|
327
|
+
guideRings.set(tier, { ring, label });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
238
330
|
// Where the view is going while a transition takes it there. A resize part-way
|
|
239
331
|
// through re-fits the destination, and a rebuild starts from it, rather than
|
|
240
332
|
// from a point along the way.
|
|
@@ -330,11 +422,20 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
330
422
|
}
|
|
331
423
|
|
|
332
424
|
const simulation = d3.forceSimulation(nodes)
|
|
333
|
-
.force('link', d3.forceLink(links).id(d => d.id).distance(d =>
|
|
425
|
+
.force('link', d3.forceLink(links).id(d => d.id).distance(d => (d.near ? Math.max(40, radiusOf(d.target) - radiusOf(d.source)) : radiusOf(d.target))).strength(isMobileGraph ? 0 : layered ? 0.06 : 0.1))
|
|
334
426
|
.force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
|
|
335
427
|
.force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
|
|
336
428
|
.force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
|
|
337
|
-
.force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 :
|
|
429
|
+
.force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : radiusOf(d), 0, 0).strength(layered ? 0.45 : 0.3));
|
|
430
|
+
simulation.on('end.guides', () => {
|
|
431
|
+
for (const [tier, { ring, label }] of guideRings) {
|
|
432
|
+
const out = nodes.filter((n) => n.tier === tier && n.id !== CENTER_ID).map((n) => Math.hypot(n.x || 0, n.y || 0)).sort((x, y) => x - y);
|
|
433
|
+
if (!out.length) { ring.attr('opacity', 0); label.attr('opacity', 0); continue; }
|
|
434
|
+
const r = out[Math.floor(out.length / 2)];
|
|
435
|
+
ring.attr('r', r).attr('opacity', 1);
|
|
436
|
+
label.attr('y', -r - 4).attr('opacity', 1);
|
|
437
|
+
}
|
|
438
|
+
});
|
|
338
439
|
|
|
339
440
|
// forceLink has already swapped each link's ids for the nodes themselves, so
|
|
340
441
|
// the colour is one step away. It used to search every node for every link:
|
|
@@ -344,7 +445,10 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
344
445
|
.attr('stroke-opacity', 0.15).attr('stroke-width', 0.5);
|
|
345
446
|
|
|
346
447
|
// Catalyst outer glow rings (rendered behind the nodes)
|
|
347
|
-
|
|
448
|
+
// A catalyst is the green outline on their dot (design C, with the scan
|
|
449
|
+
// bars round it); the dashed glow ring that used to sit here hid the bars,
|
|
450
|
+
// so it's only drawn for a catalyst with no bars.
|
|
451
|
+
const catalysts = nodes.filter(n => n.is_catalyst && marks.get(n.id)?.bars == null);
|
|
348
452
|
if (catalysts.length) {
|
|
349
453
|
const defs = svg.select('defs').size() ? svg.select('defs') : svg.append('defs');
|
|
350
454
|
if (!defs.select('#catalyst-glow-nm').size()) {
|
|
@@ -361,9 +465,34 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
361
465
|
.attr('class', 'catalyst-ring');
|
|
362
466
|
}
|
|
363
467
|
|
|
468
|
+
// Design C round a connection's dot: five thin bars for how much of their
|
|
469
|
+
// circle is scanned, and a small badge for how many in it are ready to scan.
|
|
470
|
+
const ringed = nodes.filter((n) => marks.has(n.id));
|
|
471
|
+
const dotRings = g.append('g').attr('class', 'dot-rings').style('pointer-events', 'none')
|
|
472
|
+
.selectAll('g').data(ringed).join('g')
|
|
473
|
+
.attr('transform', d => `translate(${d.x || 0},${d.y || 0})`);
|
|
474
|
+
dotRings.each(function (d) {
|
|
475
|
+
const { bars, ready } = marks.get(d.id);
|
|
476
|
+
const r = nodeRadius(d) + 3.5;
|
|
477
|
+
const el = d3.select(this);
|
|
478
|
+
if (bars != null) {
|
|
479
|
+
for (const seg of ringSegments(r)) {
|
|
480
|
+
el.append('path').attr('d', seg.d).attr('fill', 'none').attr('stroke-linecap', 'round')
|
|
481
|
+
.attr('stroke-width', 1.6).attr('stroke', seg.i < bars ? RING.filled : RING.empty);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (ready > 0) {
|
|
485
|
+
const at = r * 0.72;
|
|
486
|
+
el.append('circle').attr('cx', at).attr('cy', at).attr('r', 5.5).attr('fill', RING.badge);
|
|
487
|
+
el.append('text').attr('x', at).attr('y', at + 2.5).attr('text-anchor', 'middle')
|
|
488
|
+
.attr('font-size', 7).attr('font-weight', 700).attr('fill', '#fff').text(ready > 9 ? '9+' : ready);
|
|
489
|
+
}
|
|
490
|
+
});
|
|
491
|
+
|
|
364
492
|
const node = g.append('g').selectAll('circle').data(nodes).join('circle')
|
|
365
493
|
.attr('r', nodeRadius)
|
|
366
494
|
.attr('fill', d => d.id === CENTER_ID ? '#fff' : tierColors[d.tier] || '#666')
|
|
495
|
+
.attr('fill-opacity', d => (d.degree >= 3 ? 0.55 : d.degree === 2 ? 0.75 : 1))
|
|
367
496
|
.attr('stroke', d => {
|
|
368
497
|
if (d.id === CENTER_ID) return '#FFD700';
|
|
369
498
|
if (d.is_catalyst) return '#00ff88';
|
|
@@ -425,7 +554,9 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
425
554
|
setupDrag(node, simulation, CENTER_ID);
|
|
426
555
|
|
|
427
556
|
const labels = g.append('g').selectAll('text')
|
|
428
|
-
|
|
557
|
+
// Names for you, your S-tier connections and catalysts; further out (the
|
|
558
|
+
// Degree filter) only on hover, or hundreds of S-tier names pile up.
|
|
559
|
+
.data(nodes.filter(n => n.id === CENTER_ID || (n.degree === 1 && (n.tier === 'S' || n.is_catalyst))))
|
|
429
560
|
.join('text')
|
|
430
561
|
.text(d => d.is_catalyst && d.tier !== 'S' ? d.name + ' ⚡' : d.name)
|
|
431
562
|
.attr('font-size', d => d.id === CENTER_ID ? 14 : 10)
|
|
@@ -444,6 +575,7 @@ function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, fo
|
|
|
444
575
|
link.attr('x1', d => d.source.x).attr('y1', d => d.source.y).attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
|
445
576
|
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
446
577
|
catalystRings.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
578
|
+
dotRings.attr('transform', d => `translate(${d.x},${d.y})`);
|
|
447
579
|
labels.attr('x', d => d.x).attr('y', d => d.y);
|
|
448
580
|
placeRing();
|
|
449
581
|
});
|
|
@@ -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
|
|
@@ -213,49 +213,108 @@ const OrbitGraph = forwardRef(function OrbitGraph(
|
|
|
213
213
|
const links = [];
|
|
214
214
|
let sceneRadius = TIER_RING_RADIUS.B + 70;
|
|
215
215
|
if (degreesMode) {
|
|
216
|
-
//
|
|
217
|
-
//
|
|
218
|
-
//
|
|
219
|
-
|
|
220
|
-
|
|
216
|
+
// Someone you added through a circle, whose own circle is scanned, sits in
|
|
217
|
+
// the fan of the circle they came from, and their people fan out behind
|
|
218
|
+
// them a band further out, and theirs behind them, to 6th degree (Blake,
|
|
219
|
+
// 2026-09-29: Orbit stopped at each connection's 2nd degree).
|
|
220
|
+
const nestedIn = new Map();
|
|
221
|
+
const nested = new Set();
|
|
222
|
+
for (const d of d1Nodes) {
|
|
223
|
+
const from = d.connection?.unlocked_from_bridge_id;
|
|
224
|
+
if (from == null || from === d.id || !d1NodeById.has(from)) continue;
|
|
225
|
+
nested.add(d.id);
|
|
226
|
+
const list = nestedIn.get(from);
|
|
227
|
+
if (list) list.push(d); else nestedIn.set(from, [d]);
|
|
228
|
+
}
|
|
229
|
+
// Everyone reachable from someone on the ring is drawn in a fan; anyone
|
|
230
|
+
// else (a loop of introductions) goes on the ring.
|
|
231
|
+
const onRing = d1Nodes.filter((d) => !nested.has(d.id));
|
|
232
|
+
const inFans = new Set();
|
|
233
|
+
const walk = (d) => { for (const p of nestedIn.get(d.id) || []) if (!inFans.has(p.id)) { inFans.add(p.id); walk(p); } };
|
|
234
|
+
onRing.forEach(walk);
|
|
235
|
+
for (const d of d1Nodes) if (nested.has(d.id) && !inFans.has(d.id)) onRing.push(d);
|
|
236
|
+
const behind = new Map();
|
|
237
|
+
const reachOf = (d, stack = new Set()) => {
|
|
238
|
+
if (behind.has(d.id)) return behind.get(d.id);
|
|
239
|
+
if (stack.has(d.id)) return 0;
|
|
240
|
+
stack.add(d.id);
|
|
241
|
+
let count = clusters.get(d.id)?.length || 0;
|
|
242
|
+
for (const p of nestedIn.get(d.id) || []) count += 1 + reachOf(p, stack);
|
|
243
|
+
stack.delete(d.id);
|
|
244
|
+
behind.set(d.id, count);
|
|
245
|
+
return count;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
// One ring of the people you've mapped, biggest reach first, each given
|
|
249
|
+
// a wedge; their circle fans outward in arcs, the most powerful closest.
|
|
250
|
+
const ordered = [...onRing].sort((a, b) => reachOf(b) - reachOf(a));
|
|
221
251
|
const n = Math.max(1, ordered.length);
|
|
222
252
|
const R = Math.max(140, n * 9);
|
|
223
253
|
// Room in proportion to how many are behind each person (square-rooted,
|
|
224
254
|
// so one huge circle can't take the whole ring): big circles spread into
|
|
225
255
|
// fans instead of shooting out as thin spikes.
|
|
226
|
-
const weight = (d) => Math.sqrt((
|
|
256
|
+
const weight = (d) => Math.sqrt(reachOf(d) + 4);
|
|
227
257
|
const total = ordered.reduce((sum, d) => sum + weight(d), 0) || 1;
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
const
|
|
239
|
-
.sort((p, q) => (Number(q.power_score) || 0) - (Number(p.power_score) || 0));
|
|
258
|
+
|
|
259
|
+
// `owner`'s people in rows across `wedge`, from rStart out; someone they
|
|
260
|
+
// added gets their own narrower fan behind them. Returns the outer edge.
|
|
261
|
+
const placeFan = (owner, angle, wedge, rStart, depth) => {
|
|
262
|
+
const people = [
|
|
263
|
+
...(clusters.get(owner.id) || []).map((c) => ({ c })),
|
|
264
|
+
...(nestedIn.get(owner.id) || []).filter((p) => inFans.has(p.id)).map((p) => ({ added: p, c: p.connection })),
|
|
265
|
+
].sort((p, q) => (Number(q.c?.power_score) || 0) - (Number(p.c?.power_score) || 0));
|
|
266
|
+
const far = depth > 2;
|
|
267
|
+
const step = far ? 8 : 9;
|
|
268
|
+
const subs = [];
|
|
240
269
|
let placed = 0;
|
|
241
|
-
let r =
|
|
270
|
+
let r = rStart;
|
|
242
271
|
while (placed < people.length) {
|
|
243
|
-
const fit = Math.max(1, Math.floor((r * wedge) /
|
|
272
|
+
const fit = Math.max(1, Math.floor((r * wedge) / step));
|
|
244
273
|
const row = people.slice(placed, placed + fit);
|
|
245
|
-
row.forEach((
|
|
274
|
+
row.forEach((p, j) => {
|
|
246
275
|
const a = angle + (row.length === 1 ? 0 : (j / (row.length - 1) - 0.5) * wedge);
|
|
276
|
+
const x = Math.cos(a) * r;
|
|
277
|
+
const y = Math.sin(a) * r;
|
|
278
|
+
if (p.added) {
|
|
279
|
+
const d = p.added;
|
|
280
|
+
d.x = d.fx = x; d.y = d.fy = y;
|
|
281
|
+
d.hub = false; d.added = true;
|
|
282
|
+
d.r = 5 + Math.min(5, reachOf(d) / 60);
|
|
283
|
+
subs.push({ d, a });
|
|
284
|
+
return;
|
|
285
|
+
}
|
|
247
286
|
const node = {
|
|
248
|
-
id: c.id, kind: 'd2', tier: c.tier || 'D',
|
|
249
|
-
r: 1.8 + Math.min(10, Number(c.power_score) || 0) * 0.32,
|
|
250
|
-
connection: c, x
|
|
287
|
+
id: p.c.id, kind: 'd2', depth, tier: p.c.tier || 'D',
|
|
288
|
+
r: (far ? 1.3 : 1.8) + Math.min(10, Number(p.c.power_score) || 0) * (far ? 0.24 : 0.32),
|
|
289
|
+
connection: p.c, x, y,
|
|
251
290
|
};
|
|
252
291
|
node.fx = node.x; node.fy = node.y;
|
|
253
292
|
d2Nodes.push(node);
|
|
293
|
+
if (owner.added) links.push({ source: owner, target: node, faint: true });
|
|
254
294
|
});
|
|
255
295
|
placed += row.length;
|
|
256
|
-
r +=
|
|
296
|
+
r += step;
|
|
257
297
|
}
|
|
258
|
-
|
|
298
|
+
let edge = r;
|
|
299
|
+
for (const { d, a } of subs) {
|
|
300
|
+
if (depth >= 6 || !reachOf(d)) continue;
|
|
301
|
+
const sub = Math.min(wedge * 0.6, Math.max(0.12, (Math.min(reachOf(d), 60) * 8) / (r + 24)));
|
|
302
|
+
edge = Math.max(edge, placeFan(d, a, sub, r + 24, depth + 1));
|
|
303
|
+
}
|
|
304
|
+
return edge;
|
|
305
|
+
};
|
|
306
|
+
|
|
307
|
+
let cursor = -Math.PI / 2;
|
|
308
|
+
ordered.forEach((b) => {
|
|
309
|
+
const share = (weight(b) / total) * Math.PI * 2;
|
|
310
|
+
const angle = cursor + share / 2;
|
|
311
|
+
cursor += share;
|
|
312
|
+
const wedge = Math.min(share * 0.88, 1.6);
|
|
313
|
+
b.x = b.fx = Math.cos(angle) * R;
|
|
314
|
+
b.y = b.fy = Math.sin(angle) * R;
|
|
315
|
+
b.hub = true;
|
|
316
|
+
b.r = 9 + Math.min(10, (clusters.get(b.id)?.length || 0) / 40);
|
|
317
|
+
sceneRadius = Math.max(sceneRadius, placeFan(b, angle, wedge, R + b.r + 16, 2) + 10);
|
|
259
318
|
});
|
|
260
319
|
}
|
|
261
320
|
for (const [bridgeId, cluster] of degreesMode ? [] : clusters) {
|
|
@@ -324,7 +383,8 @@ const OrbitGraph = forwardRef(function OrbitGraph(
|
|
|
324
383
|
}
|
|
325
384
|
|
|
326
385
|
const link = linksLayer.selectAll('line').data(links).join('line')
|
|
327
|
-
.attr('stroke', 'rgba(255,255,255,0.08)')
|
|
386
|
+
.attr('stroke', (l) => (l.faint ? 'rgba(0,255,136,0.12)' : 'rgba(255,255,255,0.08)'))
|
|
387
|
+
.attr('stroke-width', (l) => (l.faint ? 0.5 : 1));
|
|
328
388
|
|
|
329
389
|
const node = nodesLayer.selectAll('g').data(renderNodes, (d) => d.id).join('g')
|
|
330
390
|
.attr('cursor', 'pointer');
|
|
@@ -333,7 +393,7 @@ const OrbitGraph = forwardRef(function OrbitGraph(
|
|
|
333
393
|
const g = select(this);
|
|
334
394
|
|
|
335
395
|
if (d.kind === 'd2') {
|
|
336
|
-
g.attr('opacity', degreesMode ? 0.85 : 0.55);
|
|
396
|
+
g.attr('opacity', degreesMode ? (d.depth > 2 ? 0.6 : 0.85) : 0.55);
|
|
337
397
|
g.append('circle').attr('class', 'og-base').attr('r', d.r)
|
|
338
398
|
.attr('fill', TIER_COLORS[d.tier] || TIER_COLORS.D);
|
|
339
399
|
return; // selection ring is created on demand, see applySelection
|
|
@@ -347,8 +407,10 @@ const OrbitGraph = forwardRef(function OrbitGraph(
|
|
|
347
407
|
// selecting promotes it — the tooltip carries the detail, so nothing is
|
|
348
408
|
// hidden, it is just not all shouted at once.
|
|
349
409
|
if (!isUser && !d.hub) {
|
|
350
|
-
g.attr('opacity', 0.75);
|
|
351
|
-
|
|
410
|
+
g.attr('opacity', d.added ? 1 : 0.75);
|
|
411
|
+
// Someone you added through a circle: green-ringed, with their own fan behind them.
|
|
412
|
+
g.append('circle').attr('class', 'og-base').attr('r', d.r).attr('fill', color)
|
|
413
|
+
.attr('stroke', d.added ? '#00ff88' : 'none').attr('stroke-width', d.added ? 1.5 : 0);
|
|
352
414
|
return; // selection ring is created on demand, see applySelection
|
|
353
415
|
}
|
|
354
416
|
|
|
@@ -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
|