six-degrees 0.4.0-beta.1 → 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/required-server-files.json +6 -1
- package/.next/standalone/.next/routes-manifest.json +12 -1
- 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/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_not-found.html +1 -1
- package/.next/standalone/.next/server/app/_not-found.rsc +12 -12
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +10 -10
- package/.next/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/api/admin-delete/route.js +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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 +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/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/import.html +1 -1
- package/.next/standalone/.next/server/app/import.rsc +14 -14
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/import.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/import.segments/import/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/index.html +1 -1
- package/.next/standalone/.next/server/app/index.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/launch/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/launch.html +2 -2
- package/.next/standalone/.next/server/app/launch.rsc +14 -14
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/launch.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/launch.segments/launch/__PAGE__.segment.rsc +12 -12
- 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 +14 -14
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/paths.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/paths.segments/paths/__PAGE__.segment.rsc +12 -12
- 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 +14 -14
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/profile.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/profile.segments/profile/__PAGE__.segment.rsc +12 -12
- 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 +14 -14
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/queue.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/queue.segments/queue/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/scores.html +1 -1
- package/.next/standalone/.next/server/app/scores.rsc +14 -14
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/settings/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/settings/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/settings.html +1 -1
- package/.next/standalone/.next/server/app/settings.rsc +14 -14
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/settings.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/settings.segments/settings/__PAGE__.segment.rsc +12 -12
- 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 +14 -14
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +14 -14
- package/.next/standalone/.next/server/app/setup.segments/_tree.segment.rsc +2 -2
- package/.next/standalone/.next/server/app/setup.segments/setup/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0-rnla2._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__00la_y0._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__07t47cm._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0rc27za._.js → [root-of-the-server]__0bat8nx._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0cj5jqc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0e0fei3._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ljcioj._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0ljnyho._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0o5-tcg._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0qrnous._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0t3v1a1._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0uag-k5._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0um1uey._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0wmexuc._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0xu34sz._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__133knx1._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__14ou7j5._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__15-17hl._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__16iu0hv._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__19b7mig._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1bbcwub._.js +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1eris29._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1gjo_cq._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1kbl21f._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1qcm08_._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1rphsdk._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1w25q0m._.js +1 -1
- package/.next/standalone/.next/server/chunks/{[root-of-the-server]__0i5hdu4._.js → [root-of-the-server]__20tmem7._.js} +2 -2
- package/.next/standalone/.next/server/chunks/_1_fynia._.js +7 -6
- package/.next/standalone/.next/server/chunks/_1fn30_l._.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 +26 -8
- package/.next/standalone/.next/server/chunks/{lib_rpc_02ndsg2.js → lib_rpc_03i8xef.js} +1 -1
- package/.next/standalone/.next/server/chunks/{lib_rpc_08q5vp6.js → lib_rpc_08fc3rp.js} +1 -1
- package/.next/standalone/.next/server/chunks/{lib_rpc_00tx2sm.js → lib_rpc_08r2t_q.js} +1 -1
- package/.next/standalone/.next/server/chunks/{lib_rpc_02-qa4d.js → lib_rpc_0btpv1t.js} +1 -1
- package/.next/standalone/.next/server/chunks/lib_rpc_0c9d9ti.js +5 -5
- package/.next/standalone/.next/server/chunks/lib_rpc_0cm2ev3.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_0iovswz.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_0is8flp.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_0v8ajtd.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_0xgmvvt.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_144mzbf.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_1bggi0n.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_1dkcu9f.js +3 -0
- package/.next/standalone/.next/server/chunks/lib_rpc_1o0jb6w.js +3 -0
- 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/_01f38ty._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_06w_gl4._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_0hw_qnj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_13kzrwv._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_15xuwkv._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1gpbbw9._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{_0cu6-an._.js → _1vpfyd-._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_20w06i-._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_components_settings_SectorPicker_1c-29qw.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_launch_page_19t0dyd.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_page_1fazqbf.js +11 -9
- package/.next/standalone/.next/server/chunks/ssr/app_paths_page_1ol90m7.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_profile_page_0u3-l5y.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_queue_page_0c6znd6.js +5 -5
- package/.next/standalone/.next/server/chunks/ssr/app_scores_page_09wepvv.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +6 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_1bn38gs._.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_dist_client_components_server-async-storage_0t1dato.js +3 -0
- 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/0ax_9zzboyv6d.js +44 -0
- package/.next/standalone/.next/static/chunks/0cuuo2jwc9h94.js +1 -0
- package/.next/standalone/.next/static/chunks/0l1568qff0efj.js +1 -0
- package/.next/standalone/.next/static/chunks/0muwt9bykzr-1.js +1 -0
- package/.next/standalone/.next/static/chunks/10784r7w7t5gw.js +18 -0
- package/.next/standalone/.next/static/chunks/18xu0fbgo_qz-.js +1 -0
- package/.next/standalone/.next/static/chunks/28bu26ausqzmn.js +1 -0
- package/.next/standalone/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
- package/.next/standalone/.next/static/chunks/{40nvbgfn7-072.css → 2p6o90z0vgcwr.css} +1 -1
- package/.next/standalone/.next/static/chunks/2pnbm1z6j7xab.js +1 -0
- package/.next/standalone/.next/static/chunks/2xjho0lroiwlq.js +1 -0
- package/.next/standalone/.next/static/chunks/3e8zg6ctvvb8p.js +33 -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 +211 -0
- package/.next/standalone/CONTRIBUTING.md +15 -4
- package/.next/standalone/README.md +21 -15
- package/.next/standalone/SECURITY.md +10 -0
- package/.next/standalone/app/api/admin-delete/route.js +3 -0
- package/.next/standalone/app/api/ingest/route.js +19 -1
- package/.next/standalone/app/api/scraper/route.js +79 -11
- package/.next/standalone/app/api/update-images/route.js +41 -7
- package/.next/standalone/app/avatars/[file]/route.js +6 -1
- package/.next/standalone/app/components/Avatar.js +5 -2
- package/.next/standalone/app/components/ChainView.js +713 -469
- package/.next/standalone/app/components/EmptyState.js +1 -1
- package/.next/standalone/app/components/FieldStep.js +94 -0
- package/.next/standalone/app/components/FilterPanel.js +37 -1
- package/.next/standalone/app/components/ForceGraph.js +228 -774
- package/.next/standalone/app/components/GridView.js +9 -7
- package/.next/standalone/app/components/LinkedInLimits.js +40 -8
- package/.next/standalone/app/components/ListView.js +6 -4
- package/.next/standalone/app/components/MappingProgress.js +5 -6
- package/.next/standalone/app/components/OnboardingGate.js +1 -1
- package/.next/standalone/app/components/OrbitGraph.js +3 -1
- package/.next/standalone/app/components/OutlinkQuest.js +5 -4
- package/.next/standalone/app/components/RingsView.js +9 -8
- package/.next/standalone/app/components/Sidebar.js +209 -112
- package/.next/standalone/app/components/TheirCircle.js +18 -11
- package/.next/standalone/app/components/settings/SectorPicker.js +202 -0
- package/.next/standalone/app/components/settings/SectorSection.js +17 -191
- package/.next/standalone/app/components/settings/TierSection.js +7 -12
- package/.next/standalone/app/components/views.js +6 -4
- package/.next/standalone/app/favicon.ico +0 -0
- package/.next/standalone/app/globals.css +7 -0
- package/.next/standalone/app/launch/page.js +2 -2
- package/.next/standalone/app/page.js +153 -31
- package/.next/standalone/app/paths/page.js +11 -3
- package/.next/standalone/app/queue/page.js +18 -10
- package/.next/standalone/app/setup/page.js +423 -260
- package/.next/standalone/db/schema.js +15 -1
- package/.next/standalone/eslint.config.mjs +3 -3
- package/.next/standalone/install.sh +3 -3
- package/.next/standalone/lib/brokerage.js +94 -0
- package/.next/standalone/lib/chain-layout.js +228 -0
- package/.next/standalone/lib/circle.js +23 -4
- package/.next/standalone/lib/csv.js +4 -6
- package/.next/standalone/lib/data-export.js +2 -1
- package/.next/standalone/lib/data-folder.js +4 -2
- package/.next/standalone/lib/degrees.js +30 -0
- package/.next/standalone/lib/galaxy.js +12 -0
- package/.next/standalone/lib/ingest.js +18 -11
- package/.next/standalone/lib/linkedin-limits.js +35 -8
- package/.next/standalone/lib/photos.js +47 -0
- package/.next/standalone/lib/quest.js +8 -3
- package/.next/standalone/lib/reach.js +113 -0
- package/.next/standalone/lib/scan-state.js +1 -0
- package/.next/standalone/lib/scanner-setup.js +20 -1
- package/.next/standalone/lib/scoring.js +769 -18
- package/.next/standalone/lib/scraper-client.js +27 -0
- package/.next/standalone/lib/search-risk.js +38 -0
- package/.next/standalone/lib/sector-focus.js +13 -0
- package/.next/standalone/lib/separation.js +4 -2
- package/.next/standalone/lib/settings-client.js +21 -0
- package/.next/standalone/lib/settings.js +3 -1
- package/.next/standalone/lib/ties.js +23 -0
- package/.next/standalone/next.config.mjs +25 -3
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +1 -1
- package/.next/standalone/scripts/build-app.mjs +11 -2
- package/.next/standalone/scripts/check-node.mjs +1 -1
- package/.next/standalone/scripts/image_store.py +77 -21
- package/.next/standalone/scripts/scrape.py +271 -29
- package/.next/standalone/server.js +1 -1
- package/.next/static/chunks/0ax_9zzboyv6d.js +44 -0
- package/.next/static/chunks/0cuuo2jwc9h94.js +1 -0
- package/.next/static/chunks/0l1568qff0efj.js +1 -0
- package/.next/static/chunks/0muwt9bykzr-1.js +1 -0
- package/.next/static/chunks/10784r7w7t5gw.js +18 -0
- package/.next/static/chunks/18xu0fbgo_qz-.js +1 -0
- package/.next/static/chunks/28bu26ausqzmn.js +1 -0
- package/.next/static/chunks/2p5yt1o9tnxjn.js +2 -0
- package/.next/static/chunks/{40nvbgfn7-072.css → 2p6o90z0vgcwr.css} +1 -1
- package/.next/static/chunks/2pnbm1z6j7xab.js +1 -0
- package/.next/static/chunks/2xjho0lroiwlq.js +1 -0
- package/.next/static/chunks/3e8zg6ctvvb8p.js +33 -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 +211 -0
- package/README.md +21 -15
- package/SECURITY.md +10 -0
- package/db/schema.js +15 -1
- package/lib/brokerage.js +94 -0
- package/lib/chain-layout.js +228 -0
- package/lib/circle.js +23 -4
- package/lib/csv.js +4 -6
- package/lib/data-export.js +2 -1
- package/lib/data-folder.js +4 -2
- package/lib/degrees.js +30 -0
- package/lib/galaxy.js +12 -0
- package/lib/ingest.js +18 -11
- package/lib/linkedin-limits.js +35 -8
- package/lib/photos.js +47 -0
- package/lib/quest.js +8 -3
- package/lib/reach.js +113 -0
- package/lib/scan-state.js +1 -0
- package/lib/scanner-setup.js +20 -1
- package/lib/scoring.js +769 -18
- package/lib/scraper-client.js +27 -0
- package/lib/search-risk.js +38 -0
- package/lib/sector-focus.js +13 -0
- package/lib/separation.js +4 -2
- package/lib/settings-client.js +21 -0
- package/lib/settings.js +3 -1
- package/lib/ties.js +23 -0
- package/package.json +1 -1
- package/scripts/check-node.mjs +1 -1
- package/scripts/image_store.py +77 -21
- package/scripts/scrape.py +271 -29
- package/.next/standalone/.next/server/chunks/lib_rpc_0_8i3ft.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_0huvw-3.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_0ip2vdh.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_0yi9xvd.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_1jyhd0o.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_1kn59-i.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_1q2jkza.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_1vpm1sk.js +0 -3
- package/.next/standalone/.next/server/chunks/lib_rpc_1x6p_31.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0223v9s._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0bcf13g._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0wxbq78._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0z88zcl._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_1-87bs1._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_17gui6a._.js +0 -3
- package/.next/standalone/.next/static/chunks/0_yurn4q91vq8.js +0 -16
- package/.next/standalone/.next/static/chunks/0d_mh-06srh2k.js +0 -1
- package/.next/standalone/.next/static/chunks/0x3oadfikvb1c.js +0 -1
- package/.next/standalone/.next/static/chunks/1d5hl1io798n2.js +0 -1
- package/.next/standalone/.next/static/chunks/1qt67cvj9sjwi.js +0 -1
- package/.next/standalone/.next/static/chunks/1roznk7hkjes1.js +0 -44
- package/.next/standalone/.next/static/chunks/2_bf2n4gstad9.js +0 -33
- package/.next/standalone/.next/static/chunks/36gbwek52x_w-.js +0 -2
- package/.next/standalone/.next/static/chunks/3a55suosthntc.js +0 -1
- package/.next/standalone/.next/static/chunks/3fq1zq-6cfwbc.js +0 -1
- package/.next/standalone/.next/static/chunks/3nbnio971zlji.js +0 -2
- package/.next/standalone/.next/static/chunks/3s6awp3qq9s7w.js +0 -1
- package/.next/standalone/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
- package/.next/static/chunks/0_yurn4q91vq8.js +0 -16
- package/.next/static/chunks/0d_mh-06srh2k.js +0 -1
- package/.next/static/chunks/0x3oadfikvb1c.js +0 -1
- package/.next/static/chunks/1d5hl1io798n2.js +0 -1
- package/.next/static/chunks/1qt67cvj9sjwi.js +0 -1
- package/.next/static/chunks/1roznk7hkjes1.js +0 -44
- package/.next/static/chunks/2_bf2n4gstad9.js +0 -33
- package/.next/static/chunks/36gbwek52x_w-.js +0 -2
- package/.next/static/chunks/3a55suosthntc.js +0 -1
- package/.next/static/chunks/3fq1zq-6cfwbc.js +0 -1
- package/.next/static/chunks/3nbnio971zlji.js +0 -2
- package/.next/static/chunks/3s6awp3qq9s7w.js +0 -1
- package/.next/static/media/favicon.2vob68tjqpejf.ico +0 -0
- /package/.next/standalone/.next/static/{NnWlxbDF24KhrU1eHiEnv → LlDgA9g8ToBkDLlo9Qtov}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{NnWlxbDF24KhrU1eHiEnv → LlDgA9g8ToBkDLlo9Qtov}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{NnWlxbDF24KhrU1eHiEnv → LlDgA9g8ToBkDLlo9Qtov}/_ssgManifest.js +0 -0
- /package/.next/static/{NnWlxbDF24KhrU1eHiEnv → LlDgA9g8ToBkDLlo9Qtov}/_buildManifest.js +0 -0
- /package/.next/static/{NnWlxbDF24KhrU1eHiEnv → LlDgA9g8ToBkDLlo9Qtov}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{NnWlxbDF24KhrU1eHiEnv → LlDgA9g8ToBkDLlo9Qtov}/_ssgManifest.js +0 -0
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
import { useEffect, useRef, useState } from 'react';
|
|
4
4
|
import * as d3 from 'd3';
|
|
5
|
+
import { localPhoto } from '../../lib/photos';
|
|
6
|
+
import { recentre } from '../../lib/galaxy';
|
|
5
7
|
|
|
6
8
|
// Connection fields are attacker-reachable: /api/ingest and /api/update-images
|
|
7
9
|
// accept writes, and a page on any other site can POST to this app on localhost.
|
|
@@ -14,24 +16,18 @@ function esc(value) {
|
|
|
14
16
|
));
|
|
15
17
|
}
|
|
16
18
|
|
|
17
|
-
// Only paths this app itself produces, or ordinary remote images. Anything else
|
|
18
|
-
// (javascript:, data:, an attribute-escaping payload) renders nothing.
|
|
19
|
-
function safeImageUrl(url) {
|
|
20
|
-
const value = String(url ?? '');
|
|
21
|
-
return /^\/avatars\/[A-Za-z0-9_-]+\.(webp|png|jpe?g)$/.test(value) || /^https:\/\/[^"'\s<>]+$/.test(value)
|
|
22
|
-
? value
|
|
23
|
-
: null;
|
|
24
|
-
}
|
|
25
|
-
|
|
26
19
|
// The id of the node at the centre, you. Not a connection's id (those come from
|
|
27
20
|
// the database), and never shown.
|
|
28
21
|
const CENTER_ID = '__center__';
|
|
29
22
|
|
|
30
23
|
// Builds the avatar tooltip through the DOM rather than a string, so no value
|
|
31
|
-
// can break out of the attribute it is written into.
|
|
24
|
+
// can break out of the attribute it is written into. Only a photo saved on this
|
|
25
|
+
// computer (lib/photos.js): not a link to LinkedIn, and never javascript:, data:
|
|
26
|
+
// or an attribute-escaping payload. Nodes carry nothing else, and this checks
|
|
27
|
+
// again.
|
|
32
28
|
function renderPhoto(sel, d, size, borderColor) {
|
|
33
29
|
sel.selectAll('*').remove();
|
|
34
|
-
const src =
|
|
30
|
+
const src = localPhoto(d.profile_image_url);
|
|
35
31
|
if (!src) { sel.style('opacity', 0); return; }
|
|
36
32
|
sel.append('img')
|
|
37
33
|
.attr('src', src)
|
|
@@ -41,106 +37,127 @@ function renderPhoto(sel, d, size, borderColor) {
|
|
|
41
37
|
.on('error', function () { this.parentElement.style.display = 'none'; });
|
|
42
38
|
}
|
|
43
39
|
|
|
44
|
-
|
|
40
|
+
// Reduce Motion, the Mac's accessibility setting. With it on, hovering and
|
|
41
|
+
// re-fitting change at once instead of easing, and the selection ring holds still.
|
|
42
|
+
function reducedMotion() {
|
|
43
|
+
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// The selection ring pulses on an element of its own, over the graph rather
|
|
47
|
+
// than inside the <svg>. Animating anything inside the <svg> repaints every dot
|
|
48
|
+
// on every frame, which at 30,000 people keeps a processor core busy for one
|
|
49
|
+
// ring. Opacity and scale on a separate element are left to the compositor.
|
|
50
|
+
const RING_CSS = `
|
|
51
|
+
@keyframes galaxy-ring-pulse {
|
|
52
|
+
0%, 100% { opacity: 0.5; transform: scale(1); }
|
|
53
|
+
50% { opacity: 1; transform: scale(1.12); }
|
|
54
|
+
}
|
|
55
|
+
.galaxy-ring-pulse { animation: galaxy-ring-pulse 1.8s ease-in-out infinite; }
|
|
56
|
+
@media (prefers-reduced-motion: reduce) {
|
|
57
|
+
.galaxy-ring-pulse { animation: none; opacity: 0.8; }
|
|
58
|
+
}`;
|
|
59
|
+
|
|
60
|
+
export default function ForceGraph({ connections, onSelect, tierColors, focusNodeRef, userName, selectedId = null }) {
|
|
45
61
|
const svgRef = useRef(null);
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
const
|
|
50
|
-
const
|
|
51
|
-
const [dimensions, setDimensions] = useState(
|
|
62
|
+
const ringRef = useRef(null);
|
|
63
|
+
const sceneRef = useRef(null); // The scene on screen: { resize, select }
|
|
64
|
+
// The view, kept across rebuilds: the zoom, and the size of box it was set for.
|
|
65
|
+
const viewRef = useRef({ transform: null, size: null });
|
|
66
|
+
const sizeRef = useRef(null);
|
|
67
|
+
const [dimensions, setDimensions] = useState(null);
|
|
52
68
|
|
|
53
69
|
// The scene is rebuilt from scratch whenever its inputs change, so only real
|
|
54
70
|
// changes should count. A parent re-rendering hands over a new onSelect every
|
|
55
71
|
// time; reading it through a ref keeps the scene from resetting on every click.
|
|
56
72
|
const onSelectRef = useRef(onSelect);
|
|
57
73
|
useEffect(() => { onSelectRef.current = onSelect; });
|
|
58
|
-
|
|
59
|
-
const sceneDegree2 = mode === 'degrees' ? degree2 : null;
|
|
74
|
+
const selectedIdRef = useRef(selectedId);
|
|
60
75
|
|
|
61
76
|
useEffect(() => {
|
|
62
77
|
const container = svgRef.current?.parentElement;
|
|
63
78
|
if (!container) return;
|
|
64
79
|
const updateSize = () => {
|
|
65
80
|
// Only publish a genuinely new size. A fresh object every observation is
|
|
66
|
-
// never Object.is-equal, so React re-rendered
|
|
67
|
-
//
|
|
68
|
-
//
|
|
81
|
+
// never Object.is-equal, so React re-rendered on every callback —
|
|
82
|
+
// including the sub-pixel churn a scrollbar appearing and disappearing
|
|
83
|
+
// produces.
|
|
69
84
|
const width = Math.round(container.clientWidth);
|
|
70
85
|
const height = Math.round(container.clientHeight);
|
|
86
|
+
sizeRef.current = { width, height };
|
|
71
87
|
setDimensions((prev) =>
|
|
72
|
-
prev.width === width && prev.height === height ? prev : { width, height }
|
|
88
|
+
prev && prev.width === width && prev.height === height ? prev : { width, height }
|
|
73
89
|
);
|
|
74
90
|
};
|
|
75
91
|
updateSize();
|
|
76
|
-
// A resize
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
|
|
80
|
-
const ro = new ResizeObserver(() => {
|
|
81
|
-
clearTimeout(timer);
|
|
82
|
-
timer = setTimeout(updateSize, 150);
|
|
83
|
-
});
|
|
92
|
+
// A resize only re-fits the view (below), so it no longer waits to settle.
|
|
93
|
+
// It used to rebuild the whole scene, and a rebuild that itself changed the
|
|
94
|
+
// size never stopped (TRAPS §29).
|
|
95
|
+
const ro = new ResizeObserver(updateSize);
|
|
84
96
|
ro.observe(container);
|
|
85
|
-
return () =>
|
|
97
|
+
return () => ro.disconnect();
|
|
86
98
|
}, []);
|
|
87
99
|
|
|
88
|
-
//
|
|
100
|
+
// Selection moves the ring; it never rebuilds the scene.
|
|
89
101
|
useEffect(() => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
102
|
+
selectedIdRef.current = selectedId;
|
|
103
|
+
sceneRef.current?.select(selectedId);
|
|
104
|
+
}, [selectedId]);
|
|
105
|
+
|
|
106
|
+
// Nor does a new size. Opening the side panel or the Filter panel used to
|
|
107
|
+
// rebuild the Galaxy: the layout started over, the selection ring was
|
|
108
|
+
// erased, and at 30,000 people the page stalled. Now the view slides over,
|
|
109
|
+
// at the same zoom, so what was in the middle stays in the middle.
|
|
110
|
+
useEffect(() => {
|
|
111
|
+
if (dimensions) sceneRef.current?.resize(dimensions);
|
|
112
|
+
}, [dimensions]);
|
|
94
113
|
|
|
114
|
+
const measured = dimensions !== null;
|
|
95
115
|
useEffect(() => {
|
|
96
|
-
if (!svgRef.current || !connections.length) return;
|
|
97
|
-
let simulation = null;
|
|
98
|
-
const { width, height } = dimensions;
|
|
116
|
+
if (!measured || !svgRef.current || !connections.length) return;
|
|
99
117
|
const svg = d3.select(svgRef.current);
|
|
100
118
|
svg.selectAll('*').remove();
|
|
101
119
|
|
|
102
|
-
//
|
|
103
|
-
const
|
|
104
|
-
|
|
105
|
-
const
|
|
120
|
+
// The view as it was, moved for any change of size since it was set.
|
|
121
|
+
const size = sizeRef.current;
|
|
122
|
+
const { transform, size: setFor } = viewRef.current;
|
|
123
|
+
const saved = transform ? recentre(transform, setFor, size) : null;
|
|
106
124
|
|
|
107
125
|
const select = (d) => onSelectRef.current?.(d);
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
simulation = renderNetworkMode(svg, width, height, connections, select, tierColors, focusNodeRef, savedTransform, zoomTransformRef, userName);
|
|
112
|
-
}
|
|
126
|
+
const scene = renderNetworkMode(svg, ringRef.current, size, connections, select, tierColors, focusNodeRef, saved, viewRef, userName);
|
|
127
|
+
sceneRef.current = scene;
|
|
128
|
+
scene.select(selectedIdRef.current);
|
|
113
129
|
|
|
114
130
|
return () => {
|
|
115
131
|
// Stop the force simulation this render started.
|
|
116
132
|
//
|
|
117
|
-
// It used to be left running. Every rebuild — and the scene
|
|
118
|
-
// any resize — added another simulation still ticking over
|
|
119
|
-
// nodes, so they fought each other and the graph shook.
|
|
120
|
-
// it worse because nothing ever stopped the old ones.
|
|
121
|
-
simulation
|
|
133
|
+
// It used to be left running. Every rebuild — and the scene used to
|
|
134
|
+
// rebuild on any resize — added another simulation still ticking over
|
|
135
|
+
// the same nodes, so they fought each other and the graph shook.
|
|
136
|
+
// Refreshing made it worse because nothing ever stopped the old ones.
|
|
137
|
+
scene.simulation.stop();
|
|
138
|
+
// And any glide or fly-to still under way, which would otherwise go on
|
|
139
|
+
// moving the next scene's view. viewRef already holds where it was going.
|
|
140
|
+
svg.interrupt();
|
|
141
|
+
sceneRef.current = null;
|
|
122
142
|
d3.selectAll('.graph-tooltip').remove();
|
|
123
143
|
};
|
|
124
144
|
// focusNodeRef and userName are read at build time on purpose; see above for
|
|
125
|
-
// why onSelect is not a dependency.
|
|
145
|
+
// why onSelect is not a dependency. The size is read through sizeRef: a new
|
|
146
|
+
// size re-fits the view rather than rebuilding.
|
|
126
147
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
-
}, [connections,
|
|
148
|
+
}, [connections, tierColors, measured]);
|
|
128
149
|
|
|
129
150
|
return (
|
|
130
151
|
<div style={{ flex: 1, position: 'relative', overflow: 'hidden' }}>
|
|
131
|
-
<
|
|
132
|
-
{
|
|
133
|
-
{
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
background: 'rgba(255,255,255,0.1)', color: '#fff', fontSize: 12, fontWeight: 600,
|
|
141
|
-
backdropFilter: 'blur(8px)',
|
|
142
|
-
}}>← All Bridges</button>
|
|
143
|
-
)}
|
|
152
|
+
<style>{RING_CSS}</style>
|
|
153
|
+
<svg ref={svgRef} width={dimensions?.width ?? 800} height={dimensions?.height ?? 600} style={{ background: '#0a0a1a' }} />
|
|
154
|
+
{/* The selection ring. The scene places it over the selected dot. */}
|
|
155
|
+
<div ref={ringRef} className="galaxy-ring" style={{
|
|
156
|
+
position: 'absolute', left: 0, top: 0, display: 'none',
|
|
157
|
+
pointerEvents: 'none', transformOrigin: '0 0',
|
|
158
|
+
}}>
|
|
159
|
+
<div className="galaxy-ring-pulse" style={{ boxSizing: 'border-box', border: '2.5px solid', borderRadius: '50%' }} />
|
|
160
|
+
</div>
|
|
144
161
|
<div style={{ position: 'absolute', bottom: 20, left: 20, display: 'flex', gap: 12, fontSize: 11, color: '#888' }}>
|
|
145
162
|
{Object.entries(tierColors).map(([tier, color]) => (
|
|
146
163
|
<span key={tier} style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
@@ -148,17 +165,6 @@ export default function ForceGraph({ connections, degree2 = [], onSelect, tierCo
|
|
|
148
165
|
{tier}
|
|
149
166
|
</span>
|
|
150
167
|
))}
|
|
151
|
-
{mode === 'degrees' && (<>
|
|
152
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
153
|
-
<span style={{ width: 8, height: 8, borderRadius: 0, border: '2px solid #00ff88', background: 'transparent', display: 'inline-block' }} />
|
|
154
|
-
Mutual
|
|
155
|
-
</span>
|
|
156
|
-
<span style={{ display: 'flex', alignItems: 'center', gap: 4 }}>
|
|
157
|
-
<span style={{ width: 8, height: 8, borderRadius: '50%', background: '#00ff88', display: 'inline-block' }} />
|
|
158
|
-
Catalyst
|
|
159
|
-
</span>
|
|
160
|
-
</>
|
|
161
|
-
)}
|
|
162
168
|
</div>
|
|
163
169
|
</div>
|
|
164
170
|
);
|
|
@@ -183,25 +189,44 @@ function createTooltip() {
|
|
|
183
189
|
.style('border', '1px solid rgba(255,255,255,0.2)').style('max-width', '280px');
|
|
184
190
|
}
|
|
185
191
|
|
|
186
|
-
|
|
192
|
+
// A hovered dot eases up to 1.5 times its size and back, rather than jumping.
|
|
193
|
+
// The transition is named, so it never cuts off another one on the same dot.
|
|
194
|
+
function easeRadius(el, r) {
|
|
195
|
+
const dot = d3.select(el);
|
|
196
|
+
if (reducedMotion()) dot.interrupt('hover').attr('r', r);
|
|
197
|
+
else dot.transition('hover').duration(150).ease(d3.easeCubicOut).attr('r', r);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// Graph coordinates put you at 0,0, whatever the size of the box; the zoom
|
|
201
|
+
// transform places that in the box. So a new size only moves the view, and a
|
|
202
|
+
// rebuild can start from the view as it was.
|
|
203
|
+
function renderNetworkMode(svg, ring, box, connections, onSelect, tierColors, focusNodeRef, savedTransform, viewRef, userName) {
|
|
187
204
|
const centerNode = {
|
|
188
205
|
id: CENTER_ID, name: userName || 'You', tier: 'center', degree: 0,
|
|
189
|
-
power_score: 10, fx:
|
|
206
|
+
power_score: 10, fx: 0, fy: 0,
|
|
190
207
|
};
|
|
191
208
|
|
|
192
209
|
const nodes = [centerNode, ...connections.map(c => ({
|
|
193
|
-
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,
|
|
194
211
|
power_score: parseFloat(c.power_score) || 1,
|
|
195
212
|
company: c.company, role: c.role, headline: c.headline,
|
|
196
|
-
profile_url: c.profile_url, profile_image_url: c.profile_image_url,
|
|
213
|
+
profile_url: c.profile_url, profile_image_url: localPhoto(c.profile_image_url),
|
|
197
214
|
connected_date: c.connected_date,
|
|
198
215
|
seniority_score: c.seniority_score, company_prestige_score: c.company_prestige_score,
|
|
199
216
|
influence_signals: c.influence_signals,
|
|
200
217
|
is_catalyst: c.is_catalyst, catalyst_score: c.catalyst_score,
|
|
201
218
|
circle_power: c.circle_power, circle_s_count: c.circle_s_count, circle_a_count: c.circle_a_count,
|
|
202
219
|
}))];
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
|
221
|
+
|
|
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
|
+
});
|
|
205
230
|
|
|
206
231
|
const tierRadius = (tier) => {
|
|
207
232
|
switch (tier) { case 'S': return 150; case 'A': return 250; case 'B': return 350; case 'C': return 450; default: return 520; }
|
|
@@ -209,70 +234,149 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
209
234
|
|
|
210
235
|
const nodeRadius = (d) => {
|
|
211
236
|
if (d.id === CENTER_ID) return 18;
|
|
212
|
-
|
|
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
|
|
213
239
|
};
|
|
214
240
|
|
|
241
|
+
// A phone. Judged by the window, as the page judges it, not by the graph's
|
|
242
|
+
// own box: that narrowed whenever a panel opened on a laptop.
|
|
243
|
+
const isMobileGraph = window.innerWidth < 768;
|
|
244
|
+
|
|
215
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
|
+
}
|
|
265
|
+
// Where the view is going while a transition takes it there. A resize part-way
|
|
266
|
+
// through re-fits the destination, and a rebuild starts from it, rather than
|
|
267
|
+
// from a point along the way.
|
|
268
|
+
let heading = null;
|
|
216
269
|
const zoomBehavior = d3.zoom().scaleExtent([0.3, 4]).on('zoom', (e) => {
|
|
217
270
|
g.attr('transform', e.transform);
|
|
218
|
-
|
|
271
|
+
viewRef.current = { transform: heading ?? e.transform, size: box };
|
|
272
|
+
placeRing();
|
|
219
273
|
});
|
|
220
274
|
svg.call(zoomBehavior);
|
|
221
275
|
|
|
222
|
-
|
|
276
|
+
const moveView = (to, duration) => {
|
|
277
|
+
if (reducedMotion()) {
|
|
278
|
+
heading = null;
|
|
279
|
+
svg.interrupt().call(zoomBehavior.transform, to);
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
heading = to;
|
|
283
|
+
viewRef.current = { transform: to, size: box };
|
|
284
|
+
// Stopped before its first frame, by a wheel or a click at that moment, a
|
|
285
|
+
// slide reports "cancel" rather than "interrupt". Missing that left heading
|
|
286
|
+
// set, and every zoom after it was saved as this destination. A scroll
|
|
287
|
+
// already under way doesn't stop a slide, which lands here all the same.
|
|
288
|
+
svg.transition().duration(duration).call(zoomBehavior.transform, to)
|
|
289
|
+
.on('end interrupt cancel', () => { if (heading === to) heading = null; });
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
// The selection ring, tied to the selected person's dot. It is drawn again
|
|
293
|
+
// after any rebuild, and hidden while they are filtered out.
|
|
294
|
+
const pulse = ring.firstChild;
|
|
295
|
+
let ringNode = null;
|
|
296
|
+
const placeRing = () => {
|
|
297
|
+
if (!ringNode) return;
|
|
298
|
+
const t = d3.zoomTransform(svg.node());
|
|
299
|
+
ring.style.transform = `translate(${t.applyX(ringNode.x)}px, ${t.applyY(ringNode.y)}px) scale(${t.k})`;
|
|
300
|
+
};
|
|
301
|
+
const select = (id) => {
|
|
302
|
+
ringNode = id != null && id !== CENTER_ID ? nodeById.get(id) ?? null : null;
|
|
303
|
+
ring.style.display = ringNode ? 'block' : 'none';
|
|
304
|
+
if (!ringNode) return;
|
|
305
|
+
// The ring the <svg> used to draw: 8 past the dot, 2.5 wide.
|
|
306
|
+
const r = nodeRadius(ringNode) + 8;
|
|
307
|
+
pulse.style.width = pulse.style.height = `${2 * r + 2.5}px`;
|
|
308
|
+
pulse.style.margin = `${-(r + 1.25)}px`;
|
|
309
|
+
pulse.style.borderColor = tierColors[ringNode.tier] || '#FFD700';
|
|
310
|
+
placeRing();
|
|
311
|
+
};
|
|
312
|
+
|
|
313
|
+
// A new size: the view slides by half the change, at the same zoom.
|
|
314
|
+
const resize = (next) => {
|
|
315
|
+
const t = recentre(heading ?? d3.zoomTransform(svg.node()), box, next);
|
|
316
|
+
box = next;
|
|
317
|
+
moveView(d3.zoomIdentity.translate(t.x, t.y).scale(t.k), 250);
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
// Restore the previous view if there is one (filter changes); otherwise put
|
|
321
|
+
// you in the middle of the box. A phone starts zoomed out to fit, since the
|
|
322
|
+
// outer tiers reach past its edges.
|
|
223
323
|
if (savedTransform) {
|
|
224
|
-
svg.call(zoomBehavior.transform, savedTransform);
|
|
324
|
+
svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(savedTransform.x, savedTransform.y).scale(savedTransform.k));
|
|
325
|
+
} else {
|
|
326
|
+
const maxTierR = 520;
|
|
327
|
+
const fitScale = isMobileGraph && box.width > 0 && box.height > 0
|
|
328
|
+
? Math.min(box.width / (maxTierR * 2.4), box.height / (maxTierR * 2.4)) : 1;
|
|
329
|
+
svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(box.width / 2, box.height / 2).scale(fitScale));
|
|
225
330
|
}
|
|
226
331
|
|
|
227
332
|
// Expose focusNode for search — zooms to a specific node
|
|
228
333
|
if (focusNodeRef) {
|
|
229
334
|
focusNodeRef.current = (nodeId) => {
|
|
230
|
-
const target =
|
|
335
|
+
const target = nodeById.get(nodeId);
|
|
231
336
|
if (!target) return;
|
|
232
337
|
const scale = 2.5;
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
svg.transition().duration(750).call(
|
|
236
|
-
zoomBehavior.transform,
|
|
237
|
-
d3.zoomIdentity.translate(tx, ty).scale(scale)
|
|
238
|
-
);
|
|
239
|
-
// Add selection glow
|
|
240
|
-
g.selectAll('.selected-glow').remove();
|
|
241
|
-
g.append('circle')
|
|
242
|
-
.attr('class', 'selected-glow')
|
|
243
|
-
.attr('cx', target.x).attr('cy', target.y).attr('r', nodeRadius(target) + 8)
|
|
244
|
-
.attr('fill', 'none').attr('stroke', tierColors[target.tier] || '#FFD700')
|
|
245
|
-
.attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
|
|
246
|
-
.style('animation', 'selectedPulse 1.5s ease-in-out infinite');
|
|
338
|
+
moveView(d3.zoomIdentity.translate(box.width / 2 - target.x * scale, box.height / 2 - target.y * scale).scale(scale), 750);
|
|
339
|
+
select(nodeId);
|
|
247
340
|
};
|
|
248
341
|
}
|
|
249
342
|
|
|
250
343
|
// On mobile: pre-compute static positions (no simulation jitter/reset on resize)
|
|
251
|
-
const isMobileGraph = width < 768;
|
|
252
344
|
if (isMobileGraph) {
|
|
253
345
|
const tierGroups = {};
|
|
254
346
|
nodes.forEach(n => { const t = n.tier || 'D'; if (!tierGroups[t]) tierGroups[t] = []; tierGroups[t].push(n); });
|
|
255
347
|
Object.entries(tierGroups).forEach(([tier, group]) => {
|
|
256
348
|
group.forEach((n, i) => {
|
|
257
|
-
if (n.id === CENTER_ID) { n.x =
|
|
349
|
+
if (n.id === CENTER_ID) { n.x = 0; n.y = 0; n.fx = n.x; n.fy = n.y; return; }
|
|
258
350
|
const r = tierRadius(tier);
|
|
259
351
|
const angle = (i / group.length) * Math.PI * 2 - Math.PI / 2;
|
|
260
|
-
n.x =
|
|
261
|
-
n.y =
|
|
352
|
+
n.x = r * Math.cos(angle);
|
|
353
|
+
n.y = r * Math.sin(angle);
|
|
262
354
|
n.fx = n.x; n.fy = n.y; // Fixed positions — no physics
|
|
263
355
|
});
|
|
264
356
|
});
|
|
265
357
|
}
|
|
266
358
|
|
|
267
359
|
const simulation = d3.forceSimulation(nodes)
|
|
268
|
-
.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))
|
|
269
361
|
.force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
|
|
270
|
-
.force('center', isMobileGraph ? null : d3.forceCenter(
|
|
362
|
+
.force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
|
|
271
363
|
.force('collision', isMobileGraph ? null : d3.forceCollide().radius(d => nodeRadius(d) + 2))
|
|
272
|
-
.force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier),
|
|
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
|
+
});
|
|
273
374
|
|
|
375
|
+
// forceLink has already swapped each link's ids for the nodes themselves, so
|
|
376
|
+
// the colour is one step away. It used to search every node for every link:
|
|
377
|
+
// about 1.8 s at 30,000 people.
|
|
274
378
|
const link = g.append('g').selectAll('line').data(links).join('line')
|
|
275
|
-
.attr('stroke', d =>
|
|
379
|
+
.attr('stroke', d => tierColors[d.target.tier] || '#333')
|
|
276
380
|
.attr('stroke-opacity', 0.15).attr('stroke-width', 0.5);
|
|
277
381
|
|
|
278
382
|
// Catalyst outer glow rings (rendered behind the nodes)
|
|
@@ -296,6 +400,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
296
400
|
const node = g.append('g').selectAll('circle').data(nodes).join('circle')
|
|
297
401
|
.attr('r', nodeRadius)
|
|
298
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))
|
|
299
404
|
.attr('stroke', d => {
|
|
300
405
|
if (d.id === CENTER_ID) return '#FFD700';
|
|
301
406
|
if (d.is_catalyst) return '#00ff88';
|
|
@@ -309,16 +414,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
309
414
|
.style('cursor', 'pointer')
|
|
310
415
|
.on('click', (event, d) => {
|
|
311
416
|
if (d.id === CENTER_ID) return;
|
|
312
|
-
|
|
313
|
-
g.selectAll('.selected-glow').remove();
|
|
314
|
-
// Add breathing glow to selected node
|
|
315
|
-
const r = nodeRadius(d);
|
|
316
|
-
g.append('circle')
|
|
317
|
-
.attr('class', 'selected-glow')
|
|
318
|
-
.attr('cx', d.x).attr('cy', d.y).attr('r', r + 8)
|
|
319
|
-
.attr('fill', 'none').attr('stroke', tierColors[d.tier] || '#FFD700')
|
|
320
|
-
.attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
|
|
321
|
-
.style('animation', 'selectedPulse 1.5s ease-in-out infinite');
|
|
417
|
+
select(d.id);
|
|
322
418
|
onSelect(d);
|
|
323
419
|
});
|
|
324
420
|
|
|
@@ -331,7 +427,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
331
427
|
.style('z-index', 1001).style('transition', 'opacity 0.15s');
|
|
332
428
|
|
|
333
429
|
node.on('mouseover', function (event, d) {
|
|
334
|
-
|
|
430
|
+
easeRadius(this, nodeRadius(d) * 1.5);
|
|
335
431
|
if (d.profile_image_url && d.id !== CENTER_ID) {
|
|
336
432
|
const size = Math.max(48, nodeRadius(d) * 5);
|
|
337
433
|
photoTooltip.style('opacity', 1);
|
|
@@ -358,7 +454,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
358
454
|
tooltip.style('left', (event.clientX + 12) + 'px').style('top', (event.clientY - 10) + 'px');
|
|
359
455
|
}
|
|
360
456
|
}).on('mouseout', function (event, d) {
|
|
361
|
-
|
|
457
|
+
easeRadius(this, nodeRadius(d));
|
|
362
458
|
tooltip.style('opacity', 0);
|
|
363
459
|
photoTooltip.style('opacity', 0);
|
|
364
460
|
});
|
|
@@ -366,7 +462,9 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
366
462
|
setupDrag(node, simulation, CENTER_ID);
|
|
367
463
|
|
|
368
464
|
const labels = g.append('g').selectAll('text')
|
|
369
|
-
|
|
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))))
|
|
370
468
|
.join('text')
|
|
371
469
|
.text(d => d.is_catalyst && d.tier !== 'S' ? d.name + ' ⚡' : d.name)
|
|
372
470
|
.attr('font-size', d => d.id === CENTER_ID ? 14 : 10)
|
|
@@ -386,654 +484,10 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
386
484
|
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
387
485
|
catalystRings.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
388
486
|
labels.attr('x', d => d.x).attr('y', d => d.y);
|
|
389
|
-
|
|
390
|
-
g.selectAll('.selected-glow').each(function() {
|
|
391
|
-
const el = d3.select(this);
|
|
392
|
-
const match = nodes.find(n => n.x && Math.abs(n.x - parseFloat(el.attr('cx'))) < 30);
|
|
393
|
-
if (match) { el.attr('cx', match.x).attr('cy', match.y); }
|
|
394
|
-
});
|
|
395
|
-
|
|
396
|
-
});
|
|
397
|
-
|
|
398
|
-
// Auto-fit zoom ONLY on small screens (mobile) where content overflows
|
|
399
|
-
if (!savedTransform && width > 0 && width < 768) {
|
|
400
|
-
const maxTierR = 520;
|
|
401
|
-
const fitScale = Math.min(width / (maxTierR * 2.4), height / (maxTierR * 2.4));
|
|
402
|
-
const initialTransform = d3.zoomIdentity.translate(width / 2, height / 2).scale(fitScale).translate(-width / 2, -height / 2);
|
|
403
|
-
svg.call(zoomBehavior.transform, initialTransform);
|
|
404
|
-
if (zoomTransformRef) zoomTransformRef.current = initialTransform;
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
return simulation;
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
function renderDegreesMode(svg, width, height, allD1, degree2, onSelect, tierColors, savedTransform, zoomTransformRef, userName, zoomToClusterRef, zoomOutRef) {
|
|
411
|
-
const d1Urls = new Set(allD1.map(c => c.profile_url));
|
|
412
|
-
const bridgeIds = [...new Set(degree2.map(d => d.source_connection_id).filter(Boolean))];
|
|
413
|
-
const bridges = allD1.filter(c => bridgeIds.includes(c.id));
|
|
414
|
-
const numBridges = Math.max(bridges.length, 1);
|
|
415
|
-
const cx = width / 2, cy = height / 2;
|
|
416
|
-
|
|
417
|
-
// Per-tier orbit distance from the centre (you): S closest, A further, etc.
|
|
418
|
-
const bridgeTierOrbit = { S: 350, A: 600, B: 820, C: 1000, D: 1150 };
|
|
419
|
-
const tierRing = { S: 40, A: 70, B: 100, C: 125, D: 145 };
|
|
420
|
-
const tierOrder = ['S', 'A', 'B', 'C', 'D'];
|
|
421
|
-
|
|
422
|
-
// --- Build nodes ---
|
|
423
|
-
const centerNode = {
|
|
424
|
-
id: CENTER_ID, name: userName || 'You', tier: 'center', degree: 0,
|
|
425
|
-
power_score: 10, nodeType: 'center', fx: cx, fy: cy,
|
|
426
|
-
};
|
|
427
|
-
|
|
428
|
-
// Group bridges by tier so same-tier bridges are spaced around their ring
|
|
429
|
-
const tierCounts = {};
|
|
430
|
-
const tierIndex = {};
|
|
431
|
-
bridges.forEach(c => {
|
|
432
|
-
const t = c.tier || 'D';
|
|
433
|
-
tierCounts[t] = (tierCounts[t] || 0) + 1;
|
|
434
|
-
tierIndex[c.id] = (tierIndex[c.id] || 0);
|
|
435
|
-
});
|
|
436
|
-
const tierCounter = {};
|
|
437
|
-
const bridgeNodes = bridges.map((c) => {
|
|
438
|
-
const t = c.tier || 'D';
|
|
439
|
-
const count = tierCounts[t] || 1;
|
|
440
|
-
const idx = tierCounter[t] || 0;
|
|
441
|
-
tierCounter[t] = idx + 1;
|
|
442
|
-
const orbitR = bridgeTierOrbit[t] || 600;
|
|
443
|
-
const angleOffset = { S: 0, A: Math.PI / 7, B: Math.PI / 4, C: Math.PI / 3, D: Math.PI / 2.5 };
|
|
444
|
-
const angle = (2 * Math.PI * idx) / count + (angleOffset[t] || 0);
|
|
445
|
-
return {
|
|
446
|
-
id: c.id, name: c.name, tier: c.tier, degree: 1, nodeType: 'bridge',
|
|
447
|
-
power_score: parseFloat(c.power_score) || 1,
|
|
448
|
-
company: c.company, role: c.role, headline: c.headline,
|
|
449
|
-
profile_url: c.profile_url, profile_image_url: c.profile_image_url,
|
|
450
|
-
seniority_score: c.seniority_score,
|
|
451
|
-
company_prestige_score: c.company_prestige_score,
|
|
452
|
-
is_catalyst: c.is_catalyst, catalyst_score: c.catalyst_score,
|
|
453
|
-
circle_power: c.circle_power, circle_s_count: c.circle_s_count, circle_a_count: c.circle_a_count,
|
|
454
|
-
unlocked_from_bridge_id: c.unlocked_from_bridge_id,
|
|
455
|
-
unlocked_from_name: c.unlocked_from_name,
|
|
456
|
-
_orbitRadius: orbitR,
|
|
457
|
-
x: cx + Math.cos(angle) * orbitR,
|
|
458
|
-
y: cy + Math.sin(angle) * orbitR,
|
|
459
|
-
};
|
|
460
|
-
});
|
|
461
|
-
|
|
462
|
-
const d2Nodes = [];
|
|
463
|
-
bridgeNodes.forEach(bridge => {
|
|
464
|
-
const myD2 = degree2.filter(c => c.source_connection_id === bridge.id);
|
|
465
|
-
const byTier = {};
|
|
466
|
-
tierOrder.forEach(t => { byTier[t] = []; });
|
|
467
|
-
myD2.forEach(c => { (byTier[c.tier] || byTier['D']).push(c); });
|
|
468
|
-
tierOrder.forEach(tier => {
|
|
469
|
-
const group = byTier[tier];
|
|
470
|
-
const ring = tierRing[tier];
|
|
471
|
-
group.forEach((c, i) => {
|
|
472
|
-
const angle = (2 * Math.PI * i) / Math.max(group.length, 1) - Math.PI / 2;
|
|
473
|
-
const isMutual = d1Urls.has(c.profile_url);
|
|
474
|
-
d2Nodes.push({
|
|
475
|
-
id: c.id, name: c.name, tier: c.tier, degree: 2, nodeType: 'degree2',
|
|
476
|
-
power_score: parseFloat(c.power_score) || 1,
|
|
477
|
-
company: c.company, role: c.role, headline: c.headline,
|
|
478
|
-
profile_url: c.profile_url, profile_image_url: c.profile_image_url,
|
|
479
|
-
source_connection_id: c.source_connection_id,
|
|
480
|
-
seniority_score: c.seniority_score, company_prestige_score: c.company_prestige_score,
|
|
481
|
-
isMutual, unlock_status: c.unlock_status,
|
|
482
|
-
x: bridge.x + Math.cos(angle) * ring,
|
|
483
|
-
y: bridge.y + Math.sin(angle) * ring,
|
|
484
|
-
});
|
|
485
|
-
});
|
|
486
|
-
});
|
|
487
|
-
});
|
|
488
|
-
|
|
489
|
-
const nodes = [centerNode, ...bridgeNodes, ...d2Nodes];
|
|
490
|
-
const links = [
|
|
491
|
-
...bridgeNodes.map(b => ({ source: CENTER_ID, target: b.id, linkType: 'bridge' })),
|
|
492
|
-
...d2Nodes.map(d => ({ source: d.source_connection_id, target: d.id, linkType: 'degree2' })),
|
|
493
|
-
];
|
|
494
|
-
|
|
495
|
-
// Index for fast lookup in forces
|
|
496
|
-
const bridgeById = {};
|
|
497
|
-
bridgeNodes.forEach(b => { bridgeById[b.id] = b; });
|
|
498
|
-
|
|
499
|
-
const nodeRadius = (d) => {
|
|
500
|
-
if (d.nodeType === 'center') return 24;
|
|
501
|
-
if (d.nodeType === 'bridge') return 16;
|
|
502
|
-
return Math.max(4, Math.min(10, (d.power_score || 1) * 1.1));
|
|
503
|
-
};
|
|
504
|
-
|
|
505
|
-
// --- SVG setup ---
|
|
506
|
-
const g = svg.append('g');
|
|
507
|
-
const zoomBehavior = d3.zoom().scaleExtent([0.15, 5]).on('zoom', (e) => {
|
|
508
|
-
g.attr('transform', e.transform);
|
|
509
|
-
if (zoomTransformRef) zoomTransformRef.current = e.transform;
|
|
510
|
-
});
|
|
511
|
-
svg.call(zoomBehavior);
|
|
512
|
-
|
|
513
|
-
// Restore previous zoom position if available
|
|
514
|
-
if (savedTransform) {
|
|
515
|
-
svg.call(zoomBehavior.transform, savedTransform);
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
// Expose zoom-to-cluster: smoothly zoom into a bridge node's position
|
|
519
|
-
if (zoomToClusterRef) {
|
|
520
|
-
zoomToClusterRef.current = (bridgeNode) => {
|
|
521
|
-
const scale = 2.5;
|
|
522
|
-
const tx = width / 2 - bridgeNode.x * scale;
|
|
523
|
-
const ty = height / 2 - bridgeNode.y * scale;
|
|
524
|
-
svg.transition().duration(750).call(
|
|
525
|
-
zoomBehavior.transform,
|
|
526
|
-
d3.zoomIdentity.translate(tx, ty).scale(scale)
|
|
527
|
-
);
|
|
528
|
-
};
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
// Expose zoom-out: return to the auto-fit view
|
|
532
|
-
if (zoomOutRef) {
|
|
533
|
-
zoomOutRef.current = () => {
|
|
534
|
-
const maxOrbit = bridgeNodes.length > 0 ? Math.max(...bridgeNodes.map(b => b._orbitRadius || 500)) : 500;
|
|
535
|
-
const fitR = maxOrbit + 200;
|
|
536
|
-
const fitScale = Math.min(width / (fitR * 2.2), height / (fitR * 2.2), 0.85);
|
|
537
|
-
svg.transition().duration(750).call(
|
|
538
|
-
zoomBehavior.transform,
|
|
539
|
-
d3.zoomIdentity.translate(width / 2, height / 2).scale(fitScale).translate(-cx, -cy)
|
|
540
|
-
);
|
|
541
|
-
};
|
|
542
|
-
}
|
|
543
|
-
|
|
544
|
-
// --- Defs: gradients, glows, filters ---
|
|
545
|
-
const defs = svg.append('defs');
|
|
546
|
-
const baseClusterColors = ['#FFD700', '#FF6B35', '#9B59B6', '#3498DB'];
|
|
547
|
-
let colorIdx = 0;
|
|
548
|
-
const CATALYST_COLOR = '#00ff88';
|
|
549
|
-
bridgeNodes.forEach((bridge, bi) => {
|
|
550
|
-
const color = bridge.is_catalyst ? CATALYST_COLOR : baseClusterColors[colorIdx++ % baseClusterColors.length];
|
|
551
|
-
bridge._clusterColor = color;
|
|
552
|
-
const grad = defs.append('radialGradient').attr('id', `nebula-${bi}`);
|
|
553
|
-
grad.append('stop').attr('offset', '0%').attr('stop-color', color).attr('stop-opacity', 0.08);
|
|
554
|
-
grad.append('stop').attr('offset', '40%').attr('stop-color', color).attr('stop-opacity', 0.03);
|
|
555
|
-
grad.append('stop').attr('offset', '100%').attr('stop-color', color).attr('stop-opacity', 0);
|
|
556
|
-
});
|
|
557
|
-
const glowFilter = defs.append('filter').attr('id', 'glow').attr('x', '-50%').attr('y', '-50%').attr('width', '200%').attr('height', '200%');
|
|
558
|
-
glowFilter.append('feGaussianBlur').attr('stdDeviation', '4').attr('result', 'blur');
|
|
559
|
-
glowFilter.append('feMerge').html('<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>');
|
|
560
|
-
const bridgeGlowF = defs.append('filter').attr('id', 'bridge-glow').attr('x', '-80%').attr('y', '-80%').attr('width', '260%').attr('height', '260%');
|
|
561
|
-
bridgeGlowF.append('feGaussianBlur').attr('stdDeviation', '8').attr('result', 'blur');
|
|
562
|
-
bridgeGlowF.append('feMerge').html('<feMergeNode in="blur"/><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>');
|
|
563
|
-
defs.append('style').text(`
|
|
564
|
-
@keyframes pulse { 0%,100% { opacity: 0.6; } 50% { opacity: 1; } }
|
|
565
|
-
@keyframes selectedPulse { 0%,100% { stroke-opacity: 0.4; r: attr(r); } 50% { stroke-opacity: 1; } }
|
|
566
|
-
.bridge-pulse { animation: pulse 2s ease-in-out infinite; }
|
|
567
|
-
@keyframes boundary-breathe { 0%,100% { stroke-opacity: 0.5; } 50% { stroke-opacity: 0.75; } }
|
|
568
|
-
.boundary-ring { animation: boundary-breathe 3s ease-in-out infinite; }
|
|
569
|
-
`);
|
|
570
|
-
const boundaryGlow = defs.append('filter').attr('id', 'boundary-glow').attr('x', '-20%').attr('y', '-20%').attr('width', '140%').attr('height', '140%');
|
|
571
|
-
boundaryGlow.append('feGaussianBlur').attr('stdDeviation', '3').attr('result', 'blur');
|
|
572
|
-
boundaryGlow.append('feMerge').html('<feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/>');
|
|
573
|
-
|
|
574
|
-
// --- Cluster decoration groups (move as a unit on tick) ---
|
|
575
|
-
const clusterGroups = bridgeNodes.map((bridge, bi) => {
|
|
576
|
-
const cg = g.append('g');
|
|
577
|
-
const color = bridge._clusterColor;
|
|
578
|
-
const d2Count = d2Nodes.filter(n => n.source_connection_id === bridge.id).length;
|
|
579
|
-
const sCount = d2Nodes.filter(n => n.source_connection_id === bridge.id && n.tier === 'S').length;
|
|
580
|
-
|
|
581
|
-
// Nebula background
|
|
582
|
-
cg.append('circle').attr('r', 200).attr('fill', `url(#nebula-${bi})`);
|
|
583
|
-
// Tier band separators — filled rings between each tier for visual layering
|
|
584
|
-
const tierBands = [
|
|
585
|
-
{ inner: 0, outer: 55, color: tierColors['S'], opacity: 0.06 }, // S zone (innermost)
|
|
586
|
-
{ inner: 55, outer: 85, color: tierColors['A'], opacity: 0.05 }, // A zone
|
|
587
|
-
{ inner: 85, outer: 112, color: tierColors['B'], opacity: 0.04 }, // B zone
|
|
588
|
-
{ inner: 112, outer: 137, color: tierColors['C'], opacity: 0.03 },// C zone
|
|
589
|
-
{ inner: 137, outer: 165, color: tierColors['D'], opacity: 0.02 },// D zone (outermost)
|
|
590
|
-
];
|
|
591
|
-
tierBands.forEach(band => {
|
|
592
|
-
// Filled band
|
|
593
|
-
const arc = d3.arc().innerRadius(band.inner).outerRadius(band.outer).startAngle(0).endAngle(2 * Math.PI);
|
|
594
|
-
cg.append('path').attr('d', arc()).attr('fill', band.color).attr('opacity', band.opacity);
|
|
595
|
-
});
|
|
596
|
-
// Tier ring lines — colored per tier
|
|
597
|
-
const ringDefs = [
|
|
598
|
-
{ r: 40, color: tierColors['S'], w: 1.5, dash: 'none' },
|
|
599
|
-
{ r: 70, color: tierColors['A'], w: 0.8, dash: '4,4' },
|
|
600
|
-
{ r: 100, color: tierColors['B'], w: 0.8, dash: '4,4' },
|
|
601
|
-
{ r: 125, color: tierColors['C'], w: 0.6, dash: '3,5' },
|
|
602
|
-
{ r: 145, color: tierColors['D'], w: 0.6, dash: '3,5' },
|
|
603
|
-
];
|
|
604
|
-
ringDefs.forEach(rd => {
|
|
605
|
-
cg.append('circle').attr('r', rd.r).attr('fill', 'none')
|
|
606
|
-
.attr('stroke', rd.color).attr('stroke-opacity', 0.2)
|
|
607
|
-
.attr('stroke-width', rd.w).attr('stroke-dasharray', rd.dash);
|
|
608
|
-
});
|
|
609
|
-
// Glow halo
|
|
610
|
-
cg.append('circle').attr('r', 25).attr('fill', color).attr('opacity', 0.15).attr('class', 'bridge-pulse');
|
|
611
|
-
// Catalyst badge
|
|
612
|
-
if (bridge.is_catalyst) {
|
|
613
|
-
cg.append('rect').attr('x', -40).attr('y', -185).attr('width', 80).attr('height', 18)
|
|
614
|
-
.attr('rx', 9).attr('fill', CATALYST_COLOR).attr('opacity', 0.9);
|
|
615
|
-
cg.append('text').attr('y', -172).text('CATALYST').attr('fill', '#000').attr('font-size', 9)
|
|
616
|
-
.attr('font-weight', 800).attr('text-anchor', 'middle').attr('letter-spacing', 1);
|
|
617
|
-
}
|
|
618
|
-
// Title
|
|
619
|
-
cg.append('text').attr('y', bridge.is_catalyst ? -160 : -170)
|
|
620
|
-
.text(bridge.name + (bridge.company ? ' · ' + bridge.company : ''))
|
|
621
|
-
.attr('fill', color).attr('font-size', 10).attr('font-weight', 600)
|
|
622
|
-
.attr('text-anchor', 'middle').attr('opacity', 0.8);
|
|
623
|
-
cg.append('text').attr('y', bridge.is_catalyst ? -148 : -158)
|
|
624
|
-
.text(d2Count + ' connections · ' + sCount + ' S-tier')
|
|
625
|
-
.attr('fill', 'rgba(255,255,255,0.35)').attr('font-size', 8).attr('text-anchor', 'middle');
|
|
626
|
-
|
|
627
|
-
return cg;
|
|
628
|
-
});
|
|
629
|
-
|
|
630
|
-
// --- Animated cluster boundaries ---
|
|
631
|
-
// Each cluster gets a solid glowing ring that NEVER overlaps another cluster.
|
|
632
|
-
// Where two clusters are close, both boundaries flatten at the exact midpoint.
|
|
633
|
-
const boundaryR = 170;
|
|
634
|
-
const boundaryPoints = 64;
|
|
635
|
-
const boundaryPaths = bridgeNodes.map((bridge, bi) => {
|
|
636
|
-
// Boundary color follows the outermost tier (D) — creates the "containment" feel
|
|
637
|
-
// Inner tiers glow their own color, outer boundary is neutral
|
|
638
|
-
return g.append('path')
|
|
639
|
-
.attr('fill', 'none')
|
|
640
|
-
.attr('stroke', tierColors['D'])
|
|
641
|
-
.attr('stroke-opacity', 0.5)
|
|
642
|
-
.attr('stroke-width', 2.5)
|
|
643
|
-
.attr('stroke-linecap', 'round')
|
|
644
|
-
.attr('stroke-linejoin', 'round')
|
|
645
|
-
.attr('filter', 'url(#boundary-glow)')
|
|
646
|
-
.attr('class', 'boundary-ring');
|
|
647
|
-
});
|
|
648
|
-
|
|
649
|
-
function updateBoundaries() {
|
|
650
|
-
bridgeNodes.forEach((bridge, bi) => {
|
|
651
|
-
const pts = [];
|
|
652
|
-
for (let p = 0; p < boundaryPoints; p++) {
|
|
653
|
-
const angle = (2 * Math.PI * p) / boundaryPoints;
|
|
654
|
-
let r = boundaryR;
|
|
655
|
-
|
|
656
|
-
// Soft deform: boundary bends smoothly toward nearby clusters, never crossing midpoint
|
|
657
|
-
for (let oi = 0; oi < bridgeNodes.length; oi++) {
|
|
658
|
-
if (bi === oi) continue;
|
|
659
|
-
const other = bridgeNodes[oi];
|
|
660
|
-
const dx = other.x - bridge.x, dy = other.y - bridge.y;
|
|
661
|
-
const distBetween = Math.sqrt(dx * dx + dy * dy);
|
|
662
|
-
if (distBetween < boundaryR * 2.5) {
|
|
663
|
-
const angleToOther = Math.atan2(dy, dx);
|
|
664
|
-
let angleDiff = Math.abs(angle - angleToOther);
|
|
665
|
-
if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
|
|
666
|
-
if (angleDiff < Math.PI / 1.5) {
|
|
667
|
-
const maxR = (distBetween / 2) - 6;
|
|
668
|
-
// Smooth cosine blend — creates a gentle dent, not a hard flat
|
|
669
|
-
const influence = Math.pow(Math.cos(angleDiff * 1.5 / Math.PI * (Math.PI / 2)), 2);
|
|
670
|
-
const targetR = Math.max(maxR, 35);
|
|
671
|
-
if (targetR < r) {
|
|
672
|
-
r = r * (1 - influence) + targetR * influence;
|
|
673
|
-
}
|
|
674
|
-
}
|
|
675
|
-
}
|
|
676
|
-
}
|
|
677
|
-
pts.push([bridge.x + Math.cos(angle) * r, bridge.y + Math.sin(angle) * r]);
|
|
678
|
-
}
|
|
679
|
-
boundaryPaths[bi].attr('d', d3.line().curve(d3.curveCardinalClosed.tension(0.3))(pts));
|
|
680
|
-
});
|
|
681
|
-
}
|
|
682
|
-
|
|
683
|
-
// --- Bridge connecting lines ---
|
|
684
|
-
const bridgeLines = g.append('g').selectAll('line').data(bridgeNodes).join('line')
|
|
685
|
-
.attr('stroke', d => d._clusterColor).attr('stroke-opacity', 0.4).attr('stroke-width', 2.5);
|
|
686
|
-
|
|
687
|
-
// --- Discovery chain lines ---
|
|
688
|
-
const chainData = bridgeNodes.filter(b => b.unlocked_from_bridge_id && bridgeById[b.unlocked_from_bridge_id]);
|
|
689
|
-
const chainLines = g.append('g').selectAll('line').data(chainData).join('line')
|
|
690
|
-
.attr('stroke', '#FF6B35').attr('stroke-width', 1.5)
|
|
691
|
-
.attr('stroke-dasharray', '6,4').attr('stroke-opacity', 0.5);
|
|
692
|
-
const chainLabels = g.append('g').selectAll('text').data(chainData).join('text')
|
|
693
|
-
.text(d => 'unlocked via ' + (d.unlocked_from_name || bridgeById[d.unlocked_from_bridge_id]?.name || ''))
|
|
694
|
-
.attr('fill', 'rgba(255,107,53,0.5)').attr('font-size', 8)
|
|
695
|
-
.attr('text-anchor', 'middle').style('pointer-events', 'none');
|
|
696
|
-
|
|
697
|
-
// --- D2 links (subtle, rendered after settle) ---
|
|
698
|
-
let d2LinkGroup = null;
|
|
699
|
-
|
|
700
|
-
// --- Nodes ---
|
|
701
|
-
const node = g.append('g').selectAll('circle').data(nodes).join('circle')
|
|
702
|
-
.attr('r', nodeRadius)
|
|
703
|
-
.attr('fill', d => {
|
|
704
|
-
if (d.nodeType === 'center') return '#fff';
|
|
705
|
-
if (d.nodeType === 'bridge') return tierColors[d.tier] || '#FFD700';
|
|
706
|
-
return tierColors[d.tier] || '#666';
|
|
707
|
-
})
|
|
708
|
-
.attr('stroke', d => {
|
|
709
|
-
if (d.nodeType === 'center') return '#FFD700';
|
|
710
|
-
if (d.nodeType === 'bridge') return '#fff';
|
|
711
|
-
if (d.isMutual) return '#00ff88';
|
|
712
|
-
if (d.tier === 'S') return 'rgba(255,215,0,0.5)';
|
|
713
|
-
return 'none';
|
|
714
|
-
})
|
|
715
|
-
.attr('stroke-width', d => {
|
|
716
|
-
if (d.nodeType === 'center') return 3;
|
|
717
|
-
if (d.nodeType === 'bridge') return 3;
|
|
718
|
-
if (d.isMutual) return 2.5;
|
|
719
|
-
if (d.tier === 'S') return 1.5;
|
|
720
|
-
return 0;
|
|
721
|
-
})
|
|
722
|
-
.attr('filter', d => (d.nodeType === 'bridge') ? 'url(#bridge-glow)' : (d.tier === 'S' ? 'url(#glow)' : null))
|
|
723
|
-
.style('cursor', 'pointer');
|
|
724
|
-
|
|
725
|
-
// --- Labels ---
|
|
726
|
-
const labelNodes = nodes.filter(n =>
|
|
727
|
-
n.nodeType === 'center' || n.nodeType === 'bridge' || (n.degree === 2 && n.power_score >= 7)
|
|
728
|
-
);
|
|
729
|
-
const labels = g.append('g').selectAll('text').data(labelNodes).join('text')
|
|
730
|
-
.text(d => d.name)
|
|
731
|
-
.attr('font-size', d => d.nodeType === 'center' ? 15 : d.nodeType === 'bridge' ? 13 : 9)
|
|
732
|
-
.attr('font-weight', d => d.nodeType === 'center' || d.nodeType === 'bridge' ? 700 : 600)
|
|
733
|
-
.attr('fill', d => {
|
|
734
|
-
if (d.nodeType === 'center') return '#fff';
|
|
735
|
-
if (d.nodeType === 'bridge') return '#fff';
|
|
736
|
-
return tierColors[d.tier] || '#FF6B35';
|
|
737
|
-
})
|
|
738
|
-
.attr('text-anchor', 'middle')
|
|
739
|
-
.attr('dy', d => nodeRadius(d) + (d.nodeType === 'bridge' ? 24 : 15))
|
|
740
|
-
.style('pointer-events', 'none')
|
|
741
|
-
.style('text-shadow', d => d.nodeType === 'bridge' ? '0 0 10px rgba(0,0,0,0.8)' : 'none');
|
|
742
|
-
|
|
743
|
-
// --- Force simulation ---
|
|
744
|
-
// Bridges: positioned by physics (orbit + charge)
|
|
745
|
-
// D2 nodes: positioned by computed angles (no physics fighting)
|
|
746
|
-
const bridgeLinks = links.filter(l => l.linkType === 'bridge');
|
|
747
|
-
|
|
748
|
-
// Pre-compute even angular positions for every d2 node per tier per cluster
|
|
749
|
-
const nodeTargets = new Map();
|
|
750
|
-
const clustersByBridge = {};
|
|
751
|
-
d2Nodes.forEach(n => {
|
|
752
|
-
const cid = n.source_connection_id;
|
|
753
|
-
if (!clustersByBridge[cid]) clustersByBridge[cid] = {};
|
|
754
|
-
const tier = n.tier || 'D';
|
|
755
|
-
if (!clustersByBridge[cid][tier]) clustersByBridge[cid][tier] = [];
|
|
756
|
-
clustersByBridge[cid][tier].push(n);
|
|
757
|
-
});
|
|
758
|
-
// Offset each tier's starting angle so rings don't stack on top of each other
|
|
759
|
-
const tierOffset = { S: 0, A: Math.PI / 5, B: Math.PI / 3, C: Math.PI / 2.2, D: Math.PI / 1.7 };
|
|
760
|
-
// Max radius is inside the boundary line
|
|
761
|
-
const maxNodeRadius = 160;
|
|
762
|
-
for (const cid in clustersByBridge) {
|
|
763
|
-
for (const tier in clustersByBridge[cid]) {
|
|
764
|
-
const group = clustersByBridge[cid][tier];
|
|
765
|
-
const radius = Math.min(tierRing[tier] || 130, maxNodeRadius);
|
|
766
|
-
const offset = tierOffset[tier] || 0;
|
|
767
|
-
group.forEach((n, i) => {
|
|
768
|
-
const angle = (2 * Math.PI * i) / group.length + offset;
|
|
769
|
-
nodeTargets.set(n.id, { angle, radius, bridgeId: cid });
|
|
770
|
-
});
|
|
771
|
-
}
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
const simulation = d3.forceSimulation(nodes)
|
|
775
|
-
.force('charge', d3.forceManyBody().strength(d => {
|
|
776
|
-
if (d.nodeType === 'center') return 0;
|
|
777
|
-
if (d.nodeType === 'bridge') return -400;
|
|
778
|
-
return 0; // d2 has NO charge — positions are computed, not physics-driven
|
|
779
|
-
}))
|
|
780
|
-
.force('bridgeOrbit', (() => {
|
|
781
|
-
let _nodes, _center;
|
|
782
|
-
const force = (alpha) => {
|
|
783
|
-
if (!_center) return;
|
|
784
|
-
const bx = _center.fx ?? _center.x, by = _center.fy ?? _center.y;
|
|
785
|
-
for (const n of _nodes) {
|
|
786
|
-
if (n.nodeType !== 'bridge') continue;
|
|
787
|
-
const targetR = n._orbitRadius || 500;
|
|
788
|
-
const dx = n.x - bx, dy = n.y - by;
|
|
789
|
-
const dist = Math.sqrt(dx * dx + dy * dy) || 1;
|
|
790
|
-
const diff = dist - targetR;
|
|
791
|
-
n.vx -= (dx / dist) * diff * alpha * 0.4;
|
|
792
|
-
n.vy -= (dy / dist) * diff * alpha * 0.4;
|
|
793
|
-
}
|
|
794
|
-
};
|
|
795
|
-
force.initialize = (n) => { _nodes = n; _center = n.find(x => x.id === CENTER_ID); };
|
|
796
|
-
return force;
|
|
797
|
-
})())
|
|
798
|
-
.force('clusterPositioning', (() => {
|
|
799
|
-
// Each d2 node has a pre-computed angle + radius relative to its bridge.
|
|
800
|
-
// This force pulls it to that exact spot. No charge needed, no bunching possible.
|
|
801
|
-
// Nodes form perfect evenly-spaced circles on each tier ring.
|
|
802
|
-
let _nodes;
|
|
803
|
-
const force = (alpha) => {
|
|
804
|
-
for (const n of _nodes) {
|
|
805
|
-
if (n.nodeType !== 'degree2') continue;
|
|
806
|
-
const target = nodeTargets.get(n.id);
|
|
807
|
-
if (!target) continue;
|
|
808
|
-
const bridge = bridgeById[target.bridgeId];
|
|
809
|
-
if (!bridge) continue;
|
|
810
|
-
let r = target.radius;
|
|
811
|
-
// Match boundary deformation exactly — dots must stay inside
|
|
812
|
-
// Uses the SAME math as updateBoundaries() so they track together
|
|
813
|
-
for (let oi = 0; oi < bridgeNodes.length; oi++) {
|
|
814
|
-
const other = bridgeNodes[oi];
|
|
815
|
-
if (other.id === target.bridgeId) continue;
|
|
816
|
-
const dx = other.x - bridge.x, dy = other.y - bridge.y;
|
|
817
|
-
const distBetween = Math.sqrt(dx * dx + dy * dy);
|
|
818
|
-
if (distBetween < boundaryR * 2.5) {
|
|
819
|
-
const angleToOther = Math.atan2(dy, dx);
|
|
820
|
-
let angleDiff = Math.abs(target.angle - angleToOther);
|
|
821
|
-
if (angleDiff > Math.PI) angleDiff = 2 * Math.PI - angleDiff;
|
|
822
|
-
if (angleDiff < Math.PI / 1.5) {
|
|
823
|
-
// Boundary sits at (distBetween/2 - 6), dots need margin inside that
|
|
824
|
-
const boundaryAtAngle = (distBetween / 2) - 6;
|
|
825
|
-
const dotMax = boundaryAtAngle - 12; // 12px inside boundary
|
|
826
|
-
const influence = Math.pow(Math.cos(angleDiff * 1.5 / Math.PI * (Math.PI / 2)), 2);
|
|
827
|
-
const targetR = Math.max(dotMax, 25);
|
|
828
|
-
if (targetR < r) {
|
|
829
|
-
r = r * (1 - influence) + targetR * influence;
|
|
830
|
-
}
|
|
831
|
-
}
|
|
832
|
-
}
|
|
833
|
-
}
|
|
834
|
-
const tx = bridge.x + Math.cos(target.angle) * r;
|
|
835
|
-
const ty = bridge.y + Math.sin(target.angle) * r;
|
|
836
|
-
n.vx += (tx - n.x) * alpha * 0.6;
|
|
837
|
-
n.vy += (ty - n.y) * alpha * 0.6;
|
|
838
|
-
}
|
|
839
|
-
};
|
|
840
|
-
force.initialize = (n) => { _nodes = n; };
|
|
841
|
-
return force;
|
|
842
|
-
})())
|
|
843
|
-
.force('collision', d3.forceCollide().radius(d => d.nodeType === 'bridge' ? 20 : 0))
|
|
844
|
-
.force('link', d3.forceLink(bridgeLinks).id(d => d.id)
|
|
845
|
-
.distance(d => d.target?._orbitRadius || 500).strength(0.01)
|
|
846
|
-
)
|
|
847
|
-
.alphaDecay(0.02)
|
|
848
|
-
.velocityDecay(0.4);
|
|
849
|
-
|
|
850
|
-
// --- Tick: update all positions ---
|
|
851
|
-
let settled = false;
|
|
852
|
-
simulation.on('tick', () => {
|
|
853
|
-
// Nodes
|
|
854
|
-
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
855
|
-
labels.attr('x', d => d.x).attr('y', d => d.y);
|
|
856
|
-
// Cluster decorations follow their bridge
|
|
857
|
-
clusterGroups.forEach((cg, i) => {
|
|
858
|
-
cg.attr('transform', `translate(${bridgeNodes[i].x},${bridgeNodes[i].y})`);
|
|
859
|
-
});
|
|
860
|
-
// Animated boundaries — deform where clusters overlap
|
|
861
|
-
updateBoundaries();
|
|
862
|
-
// Bridge connecting lines
|
|
863
|
-
const centerX = centerNode.fx ?? centerNode.x, centerY = centerNode.fy ?? centerNode.y;
|
|
864
|
-
bridgeLines.attr('x1', centerX).attr('y1', centerY)
|
|
865
|
-
.attr('x2', d => d.x).attr('y2', d => d.y);
|
|
866
|
-
// Discovery chain lines
|
|
867
|
-
chainLines
|
|
868
|
-
.attr('x1', d => bridgeById[d.unlocked_from_bridge_id]?.x || 0)
|
|
869
|
-
.attr('y1', d => bridgeById[d.unlocked_from_bridge_id]?.y || 0)
|
|
870
|
-
.attr('x2', d => d.x).attr('y2', d => d.y);
|
|
871
|
-
chainLabels
|
|
872
|
-
.attr('x', d => ((bridgeById[d.unlocked_from_bridge_id]?.x || 0) + d.x) / 2)
|
|
873
|
-
.attr('y', d => ((bridgeById[d.unlocked_from_bridge_id]?.y || 0) + d.y) / 2 - 8);
|
|
874
|
-
// D2 links — render once after simulation settles
|
|
875
|
-
if (!settled && simulation.alpha() < 0.05) {
|
|
876
|
-
settled = true;
|
|
877
|
-
const d2Links = links.filter(l => l.linkType === 'degree2');
|
|
878
|
-
d2LinkGroup = g.insert('g', ':first-child').selectAll('line').data(d2Links).join('line')
|
|
879
|
-
.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
880
|
-
.attr('x2', d => d.target.x).attr('y2', d => d.target.y)
|
|
881
|
-
.attr('stroke', 'rgba(255,255,255,0.04)').attr('stroke-width', 0.3);
|
|
882
|
-
}
|
|
883
|
-
if (d2LinkGroup) {
|
|
884
|
-
d2LinkGroup.attr('x1', d => d.source.x).attr('y1', d => d.source.y)
|
|
885
|
-
.attr('x2', d => d.target.x).attr('y2', d => d.target.y);
|
|
886
|
-
}
|
|
887
|
-
});
|
|
888
|
-
|
|
889
|
-
// --- Drag ---
|
|
890
|
-
setupDrag(node, simulation, CENTER_ID);
|
|
891
|
-
|
|
892
|
-
// --- Tooltip + Photo Hover (same as Network Map) ---
|
|
893
|
-
const tooltip = createTooltip();
|
|
894
|
-
const photoTooltip = d3.select('body').append('div')
|
|
895
|
-
.attr('class', 'graph-tooltip')
|
|
896
|
-
.style('position', 'fixed').style('top', '0px').style('left', '0px')
|
|
897
|
-
.style('pointer-events', 'none').style('opacity', 0)
|
|
898
|
-
.style('z-index', 1001).style('transition', 'opacity 0.15s');
|
|
899
|
-
|
|
900
|
-
node.on('mouseover', function (event, d) {
|
|
901
|
-
d3.select(this).attr('r', nodeRadius(d) * 1.5).attr('filter', 'url(#glow)');
|
|
902
|
-
|
|
903
|
-
// Photo hover
|
|
904
|
-
if (d.profile_image_url && d.id !== CENTER_ID) {
|
|
905
|
-
const size = Math.max(48, nodeRadius(d) * 4);
|
|
906
|
-
photoTooltip.style('opacity', 1);
|
|
907
|
-
renderPhoto(photoTooltip, d, size, tierColors[d.tier] || '#555');
|
|
908
|
-
photoTooltip
|
|
909
|
-
.style('left', (event.clientX - size / 2) + 'px')
|
|
910
|
-
.style('top', (event.clientY - size - 8) + 'px');
|
|
911
|
-
}
|
|
912
|
-
|
|
913
|
-
// Text tooltip below
|
|
914
|
-
const bridgeFor = d.nodeType === 'bridge'
|
|
915
|
-
? d2Nodes.filter(n => n.source_connection_id === d.id).length + ' connections' : null;
|
|
916
|
-
const viaBridge = d.nodeType === 'degree2'
|
|
917
|
-
? bridgeNodes.find(b => b.id === d.source_connection_id)?.name : null;
|
|
918
|
-
tooltip.style('opacity', 1)
|
|
919
|
-
.html([
|
|
920
|
-
`<strong>${esc(d.name)}</strong>`,
|
|
921
|
-
d.role ? esc(d.role.substring(0, 60)) : '',
|
|
922
|
-
d.company ? `<span style="color:${tierColors[d.tier] || '#888'}">@ ${esc(d.company)}</span>` : '',
|
|
923
|
-
d.tier && d.tier !== 'center' ? `Tier ${esc(d.tier)} · Score: ${parseFloat(d.power_score).toFixed(1)}` : '',
|
|
924
|
-
bridgeFor ? `<span style="color:#FFD700">${esc(bridgeFor)}</span>` : '',
|
|
925
|
-
viaBridge ? `<span style="color:#FF6B35">via ${esc(viaBridge)}</span>` : '',
|
|
926
|
-
d.isMutual ? '<span style="color:#00ff88">Mutual connection</span>' : '',
|
|
927
|
-
].filter(Boolean).join('<br/>'))
|
|
928
|
-
.style('left', (event.clientX + 12) + 'px').style('top', (event.clientY + 12) + 'px');
|
|
929
|
-
}).on('mousemove', function (event, d) {
|
|
930
|
-
if (d.profile_image_url && d.id !== CENTER_ID) {
|
|
931
|
-
const size = Math.max(48, nodeRadius(d) * 4);
|
|
932
|
-
photoTooltip.style('left', (event.clientX - size / 2) + 'px').style('top', (event.clientY - size - 8) + 'px');
|
|
933
|
-
}
|
|
934
|
-
tooltip.style('left', (event.clientX + 12) + 'px').style('top', (event.clientY + 12) + 'px');
|
|
935
|
-
}).on('mouseout', function (event, d) {
|
|
936
|
-
d3.select(this).attr('r', nodeRadius(d))
|
|
937
|
-
.attr('filter', (d.nodeType === 'bridge') ? 'url(#bridge-glow)' : (d.tier === 'S' ? 'url(#glow)' : null));
|
|
938
|
-
tooltip.style('opacity', 0);
|
|
939
|
-
photoTooltip.style('opacity', 0);
|
|
940
|
-
});
|
|
941
|
-
|
|
942
|
-
// --- Click: select + Connect to Unlock overlay ---
|
|
943
|
-
node.on('click', (event, d) => {
|
|
944
|
-
if (d.id === CENTER_ID) return;
|
|
945
|
-
// Selection glow
|
|
946
|
-
g.selectAll('.selected-glow').remove();
|
|
947
|
-
g.append('circle')
|
|
948
|
-
.attr('class', 'selected-glow')
|
|
949
|
-
.attr('cx', d.x).attr('cy', d.y).attr('r', nodeRadius(d) + 8)
|
|
950
|
-
.attr('fill', 'none').attr('stroke', tierColors[d.tier] || '#FFD700')
|
|
951
|
-
.attr('stroke-width', 2.5).attr('stroke-opacity', 0.8)
|
|
952
|
-
.style('animation', 'selectedPulse 1.5s ease-in-out infinite');
|
|
953
|
-
onSelect(d);
|
|
954
|
-
// Bridge click: zoom into this cluster
|
|
955
|
-
if (d.nodeType === 'bridge' && zoomToClusterRef?.current) {
|
|
956
|
-
zoomToClusterRef.current(d);
|
|
957
|
-
// Update React state via a custom event
|
|
958
|
-
window.dispatchEvent(new CustomEvent('cluster-focus', { detail: { id: d.id, name: d.name } }));
|
|
959
|
-
}
|
|
960
|
-
g.selectAll('.unlock-overlay').remove();
|
|
961
|
-
if (d.nodeType === 'degree2' && (d.tier === 'S' || d.tier === 'A') && d.unlock_status !== 'unlocked') {
|
|
962
|
-
simulation.stop();
|
|
963
|
-
const ox = d.x, oy = d.y;
|
|
964
|
-
const overlay = g.append('g').attr('class', 'unlock-overlay');
|
|
965
|
-
// Dim non-path nodes
|
|
966
|
-
node.transition().duration(300)
|
|
967
|
-
.attr('opacity', n => (n.id === d.id || n.id === CENTER_ID || n.id === d.source_connection_id) ? 1 : 0.2);
|
|
968
|
-
labels.transition().duration(300)
|
|
969
|
-
.attr('opacity', n => (n.id === d.id || n.id === CENTER_ID || n.id === d.source_connection_id) ? 1 : 0.1);
|
|
970
|
-
// Phantom cluster
|
|
971
|
-
[30, 55, 80].forEach(r => {
|
|
972
|
-
overlay.append('circle').attr('cx', ox).attr('cy', oy).attr('r', r)
|
|
973
|
-
.attr('fill', 'none').attr('stroke', 'rgba(255,215,0,0.12)')
|
|
974
|
-
.attr('stroke-dasharray', '4,4').attr('stroke-width', 0.8);
|
|
975
|
-
});
|
|
976
|
-
for (let i = 0; i < 8; i++) {
|
|
977
|
-
const a = (Math.PI * 2 * i) / 8, r = 30 + Math.random() * 50;
|
|
978
|
-
overlay.append('circle').attr('cx', ox + Math.cos(a)*r).attr('cy', oy + Math.sin(a)*r)
|
|
979
|
-
.attr('r', 3 + Math.random()*3).attr('fill', 'rgba(255,255,255,0.06)')
|
|
980
|
-
.attr('stroke', 'rgba(255,255,255,0.08)').attr('stroke-width', 0.5);
|
|
981
|
-
}
|
|
982
|
-
// Path highlight
|
|
983
|
-
const bridge = bridgeById[d.source_connection_id];
|
|
984
|
-
const center = centerNode;
|
|
985
|
-
if (center && bridge) {
|
|
986
|
-
overlay.append('line').attr('x1', center.x).attr('y1', center.y).attr('x2', bridge.x).attr('y2', bridge.y)
|
|
987
|
-
.attr('stroke', '#FFD700').attr('stroke-width', 2).attr('stroke-opacity', 0.6);
|
|
988
|
-
overlay.append('line').attr('x1', bridge.x).attr('y1', bridge.y).attr('x2', ox).attr('y2', oy)
|
|
989
|
-
.attr('stroke', '#FF6B35').attr('stroke-width', 2).attr('stroke-opacity', 0.6);
|
|
990
|
-
}
|
|
991
|
-
// Status card
|
|
992
|
-
const isPending = d.unlock_status === 'pending';
|
|
993
|
-
overlay.append('rect').attr('x', ox-70).attr('y', oy-115).attr('width', 140).attr('height', isPending ? 40 : 55)
|
|
994
|
-
.attr('rx', 8).attr('fill', 'rgba(0,0,0,0.92)')
|
|
995
|
-
.attr('stroke', isPending ? 'rgba(255,165,0,0.5)' : 'rgba(255,215,0,0.4)').attr('stroke-width', 1);
|
|
996
|
-
overlay.append('text').attr('x', ox).attr('y', oy-97)
|
|
997
|
-
.text(isPending ? 'PENDING...' : 'LOCKED PATH')
|
|
998
|
-
.attr('fill', isPending ? '#FFA500' : '#FFD700').attr('font-size', 9).attr('font-weight', 800)
|
|
999
|
-
.attr('text-anchor', 'middle').attr('letter-spacing', 1);
|
|
1000
|
-
if (!isPending) {
|
|
1001
|
-
overlay.append('rect').attr('x', ox-60).attr('y', oy-84).attr('width', 120).attr('height', 24).attr('rx', 6)
|
|
1002
|
-
.attr('fill', '#FFD700').style('cursor', 'pointer')
|
|
1003
|
-
.on('click', (e) => {
|
|
1004
|
-
e.stopPropagation();
|
|
1005
|
-
const bridgeNode = bridgeById[d.source_connection_id];
|
|
1006
|
-
fetch('/api/unlock', { method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify({connectionId:d.id, bridgeId:d.source_connection_id, bridgeName:bridgeNode?.name||''}) }).catch(()=>{});
|
|
1007
|
-
window.open(d.profile_url, '_blank');
|
|
1008
|
-
overlay.select('text').text('PENDING...');
|
|
1009
|
-
});
|
|
1010
|
-
overlay.append('text').attr('x', ox).attr('y', oy-68).text('Connect to Unlock')
|
|
1011
|
-
.attr('fill', '#000').attr('font-size', 10).attr('font-weight', 700).attr('text-anchor', 'middle').style('pointer-events', 'none');
|
|
1012
|
-
} else {
|
|
1013
|
-
overlay.append('text').attr('x', ox).attr('y', oy-82).text('Run a scan to check')
|
|
1014
|
-
.attr('fill', '#888').attr('font-size', 9).attr('text-anchor', 'middle');
|
|
1015
|
-
}
|
|
1016
|
-
event.stopPropagation();
|
|
1017
|
-
svg.on('click.unlock', () => {
|
|
1018
|
-
node.transition().duration(300).attr('opacity', 1);
|
|
1019
|
-
labels.transition().duration(300).attr('opacity', 1);
|
|
1020
|
-
g.selectAll('.unlock-overlay').remove();
|
|
1021
|
-
svg.on('click.unlock', null);
|
|
1022
|
-
simulation.alpha(0.1).restart();
|
|
1023
|
-
});
|
|
1024
|
-
}
|
|
487
|
+
placeRing();
|
|
1025
488
|
});
|
|
1026
489
|
|
|
1027
|
-
|
|
1028
|
-
if (!savedTransform) {
|
|
1029
|
-
const maxOrbit = bridgeNodes.length > 0 ? Math.max(...bridgeNodes.map(b => b._orbitRadius || 500)) : 500;
|
|
1030
|
-
const fitR = maxOrbit + 200;
|
|
1031
|
-
const fitScale = Math.min(width / (fitR * 2.2), height / (fitR * 2.2), 0.85);
|
|
1032
|
-
const initialTransform = d3.zoomIdentity.translate(width/2, height/2).scale(fitScale).translate(-cx, -cy);
|
|
1033
|
-
svg.call(zoomBehavior.transform, initialTransform);
|
|
1034
|
-
if (zoomTransformRef) zoomTransformRef.current = initialTransform;
|
|
1035
|
-
}
|
|
1036
|
-
return simulation;
|
|
490
|
+
return { simulation, resize, select };
|
|
1037
491
|
}
|
|
1038
492
|
|
|
1039
493
|
function setupDrag(node, simulation, fixedId) {
|
|
@@ -1042,6 +496,6 @@ function setupDrag(node, simulation, fixedId) {
|
|
|
1042
496
|
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
|
1043
497
|
.on('end', (event, d) => {
|
|
1044
498
|
if (!event.active) simulation.alphaTarget(0);
|
|
1045
|
-
if (d.id !== fixedId
|
|
499
|
+
if (d.id !== fixedId) { d.fx = null; d.fy = null; }
|
|
1046
500
|
}));
|
|
1047
501
|
}
|