six-degrees 0.3.0 → 0.4.0
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/app-path-routes-manifest.json +1 -0
- package/.next/standalone/.next/build-manifest.json +3 -3
- package/.next/standalone/.next/prerender-manifest.json +35 -7
- package/.next/standalone/.next/required-server-files.json +6 -1
- package/.next/standalone/.next/routes-manifest.json +18 -1
- package/.next/standalone/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/.next/standalone/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/.next/standalone/.next/server/app/_global-error.html +1 -1
- package/.next/standalone/.next/server/app/_global-error.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/.next/standalone/.next/server/app/_not-found/page.js.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 +11 -11
- package/.next/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +11 -11
- 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 +3 -3
- 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/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 +13 -13
- package/.next/standalone/.next/server/app/import.segments/_full.segment.rsc +13 -13
- 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 +13 -13
- package/.next/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +12 -12
- package/.next/standalone/.next/server/app/index.segments/_full.segment.rsc +13 -13
- 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 +13 -13
- package/.next/standalone/.next/server/app/launch.segments/_full.segment.rsc +13 -13
- 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 +13 -13
- package/.next/standalone/.next/server/app/paths.segments/_full.segment.rsc +13 -13
- 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 +13 -13
- package/.next/standalone/.next/server/app/profile.segments/_full.segment.rsc +13 -13
- 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 +13 -13
- package/.next/standalone/.next/server/app/queue.segments/_full.segment.rsc +13 -13
- 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/app-paths-manifest.json +3 -0
- package/.next/standalone/.next/server/app/scores/page/build-manifest.json +20 -0
- package/.next/standalone/.next/server/app/scores/page/next-font-manifest.json +11 -0
- package/.next/standalone/.next/server/app/scores/page/react-loadable-manifest.json +1 -0
- package/.next/standalone/.next/server/app/scores/page/server-reference-manifest.json +4 -0
- package/.next/standalone/.next/server/app/scores/page.js +13 -0
- package/.next/standalone/.next/server/app/scores/page.js.map +5 -0
- package/.next/standalone/.next/server/app/scores/page.js.nft.json +1 -0
- package/.next/standalone/.next/server/app/scores/page_client-reference-manifest.js +3 -0
- package/.next/standalone/.next/server/app/scores.html +1 -0
- package/.next/standalone/.next/server/app/scores.meta +26 -0
- package/.next/standalone/.next/server/app/scores.rsc +24 -0
- package/.next/standalone/.next/server/app/scores.segments/_full.segment.rsc +24 -0
- package/.next/standalone/.next/server/app/scores.segments/_tree.segment.rsc +4 -0
- package/.next/standalone/.next/server/app/scores.segments/scores/__PAGE__.segment.rsc +28 -0
- 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 +13 -13
- package/.next/standalone/.next/server/app/settings.segments/_full.segment.rsc +13 -13
- 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 +13 -13
- package/.next/standalone/.next/server/app/setup.segments/_full.segment.rsc +13 -13
- 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/app-paths-manifest.json +1 -0
- 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]__0i5hdu4._.js → [root-of-the-server]__03ydxxh._.js} +2 -2
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__07t47cm._.js +1 -1
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__0bat8nx._.js +8 -0
- 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/_02twlca._.js +10 -0
- package/.next/standalone/.next/server/chunks/_072jl0y._.js +1 -1
- package/.next/standalone/.next/server/chunks/_0lw8_i0._.js +1 -1
- package/.next/standalone/.next/server/chunks/_1_fynia._.js +8 -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/lib_db-client_15le7cw.js +13 -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]__0an6blh._.js → [root-of-the-server]__0zzcbqg._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/[root-of-the-server]__1194jps._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/{[root-of-the-server]__1z1j3ek._.js → [root-of-the-server]__19ip1qq._.js} +2 -2
- package/.next/standalone/.next/server/chunks/ssr/_01f38ty._.js +3 -0
- 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/_1cyxocj._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1gpbbw9._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1hgd5dp._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_1vpfyd-._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_20w06i-._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/_next-internal_server_app_scores_page_actions_0x-w58n.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 +4 -0
- 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 +3 -0
- package/.next/standalone/.next/server/chunks/ssr/app_settings_page_1f8mjj_.js +1 -1
- package/.next/standalone/.next/server/chunks/ssr/app_setup_page_1-282zx.js +2 -2
- package/.next/standalone/.next/server/chunks/ssr/lib_1bn38gs._.js +3 -0
- package/.next/standalone/.next/server/chunks/ssr/node_modules_next_0x3i8za._.js +131 -0
- package/.next/standalone/.next/server/chunks/ssr/{node_modules_next_dist_1bunoni._.js → node_modules_next_dist_1v8aef8._.js} +2 -2
- 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/next-font-manifest.js +1 -1
- package/.next/standalone/.next/server/next-font-manifest.json +4 -0
- 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/prefetch-hints.json +14 -0
- 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/0w76iraw7ij3k.js +18 -0
- package/.next/standalone/.next/static/chunks/135n87ydxjrra.js +2 -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/{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/3qh78g2aqhprd.js +2 -0
- package/.next/standalone/CHANGELOG.md +286 -0
- package/.next/standalone/CONTRIBUTING.md +15 -4
- package/.next/standalone/README.md +50 -29
- package/.next/standalone/SECURITY.md +10 -0
- package/.next/standalone/app/api/company-scores/legacy/route.js +1 -1
- package/.next/standalone/app/api/company-scores/route.js +1 -1
- package/.next/standalone/app/api/ingest/route.js +23 -2
- package/.next/standalone/app/api/outreach/route.js +107 -44
- package/.next/standalone/app/api/scraper/route.js +146 -17
- package/.next/standalone/app/api/settings/sector-preview/route.js +3 -2
- package/.next/standalone/app/api/settings/sector-suggestions/route.js +1 -1
- 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 +659 -469
- package/.next/standalone/app/components/CompanyScores.js +16 -10
- package/.next/standalone/app/components/EmptyState.js +1 -1
- package/.next/standalone/app/components/FieldStep.js +94 -0
- package/.next/standalone/app/components/ForceGraph.js +183 -768
- package/.next/standalone/app/components/GridView.js +9 -7
- package/.next/standalone/app/components/LegacyScoresCard.js +1 -1
- package/.next/standalone/app/components/LinkedInLimits.js +22 -7
- 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 +33 -1
- package/.next/standalone/app/components/OutlinkQuest.js +5 -4
- package/.next/standalone/app/components/PathsAnalyzer.js +1 -1
- package/.next/standalone/app/components/RingsView.js +9 -8
- package/.next/standalone/app/components/SeparationView.js +96 -14
- package/.next/standalone/app/components/Sidebar.js +366 -287
- package/.next/standalone/app/components/TheirCircle.js +300 -0
- package/.next/standalone/app/components/settings/SectorPicker.js +202 -0
- package/.next/standalone/app/components/settings/SectorSection.js +21 -195
- package/.next/standalone/app/components/settings/TierSection.js +99 -0
- package/.next/standalone/app/components/useRequests.js +12 -0
- package/.next/standalone/app/components/useScanner.js +13 -0
- package/.next/standalone/app/components/views.js +3 -5
- package/.next/standalone/app/globals.css +7 -0
- package/.next/standalone/app/launch/page.js +6 -3
- package/.next/standalone/app/page.js +183 -74
- package/.next/standalone/app/paths/page.js +20 -14
- package/.next/standalone/app/profile/page.js +3 -3
- package/.next/standalone/app/queue/page.js +56 -60
- package/.next/standalone/app/scores/page.js +72 -0
- package/.next/standalone/app/settings/page.js +15 -3
- package/.next/standalone/app/setup/page.js +423 -260
- package/.next/standalone/db/schema.js +2 -1
- package/.next/standalone/eslint.config.mjs +3 -3
- package/.next/standalone/install.sh +3 -3
- package/.next/standalone/lib/chain-layout.js +139 -0
- package/.next/standalone/lib/circle.js +169 -0
- package/.next/standalone/lib/companies.js +1 -1
- package/.next/standalone/lib/csv.js +21 -10
- package/.next/standalone/lib/data-export.js +2 -1
- package/.next/standalone/lib/data-folder.js +4 -2
- package/.next/standalone/lib/db-client.js +1 -0
- package/.next/standalone/lib/galaxy.js +12 -0
- package/.next/standalone/lib/ingest.js +27 -8
- package/.next/standalone/lib/legacy-offer.js +7 -6
- package/.next/standalone/lib/legacy-scores.js +2 -2
- package/.next/standalone/lib/linkedin-limits.js +30 -8
- package/.next/standalone/lib/photos.js +47 -0
- package/.next/standalone/lib/quest.js +8 -3
- package/.next/standalone/lib/rarity.js +80 -0
- package/.next/standalone/lib/reach.js +113 -0
- package/.next/standalone/lib/requests-client.js +113 -0
- package/.next/standalone/lib/rpc.js +26 -15
- package/.next/standalone/lib/scan-state.js +1 -0
- package/.next/standalone/lib/scanner-setup.js +20 -1
- package/.next/standalone/lib/score-guess.js +60 -0
- package/.next/standalone/lib/scoring.js +1761 -69
- package/.next/standalone/lib/scraper-client.js +235 -17
- package/.next/standalone/lib/sector-directory.js +1 -1
- package/.next/standalone/lib/sector-focus.js +17 -3
- package/.next/standalone/lib/separation.js +4 -2
- package/.next/standalone/lib/settings-client.js +21 -0
- package/.next/standalone/lib/settings-effects.js +19 -1
- package/.next/standalone/lib/settings.js +7 -2
- package/.next/standalone/next.config.mjs +25 -3
- package/.next/standalone/package-lock.json +2 -2
- package/.next/standalone/package.json +2 -2
- package/.next/standalone/public/demo-data.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/gen-synthetic.mjs +1 -1
- package/.next/standalone/scripts/image_store.py +77 -21
- package/.next/standalone/scripts/scrape.py +305 -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/0w76iraw7ij3k.js +18 -0
- package/.next/static/chunks/135n87ydxjrra.js +2 -0
- package/.next/static/chunks/18xu0fbgo_qz-.js +1 -0
- package/.next/static/chunks/28bu26ausqzmn.js +1 -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/3qh78g2aqhprd.js +2 -0
- package/CHANGELOG.md +286 -0
- package/README.md +50 -29
- package/SECURITY.md +10 -0
- package/db/schema.js +2 -1
- package/lib/chain-layout.js +139 -0
- package/lib/circle.js +169 -0
- package/lib/companies.js +1 -1
- package/lib/csv.js +21 -10
- package/lib/data-export.js +2 -1
- package/lib/data-folder.js +4 -2
- package/lib/db-client.js +1 -0
- package/lib/galaxy.js +12 -0
- package/lib/ingest.js +27 -8
- package/lib/legacy-offer.js +7 -6
- package/lib/legacy-scores.js +2 -2
- package/lib/linkedin-limits.js +30 -8
- package/lib/photos.js +47 -0
- package/lib/quest.js +8 -3
- package/lib/rarity.js +80 -0
- package/lib/reach.js +113 -0
- package/lib/requests-client.js +113 -0
- package/lib/rpc.js +26 -15
- package/lib/scan-state.js +1 -0
- package/lib/scanner-setup.js +20 -1
- package/lib/score-guess.js +60 -0
- package/lib/scoring.js +1761 -69
- package/lib/scraper-client.js +235 -17
- package/lib/sector-directory.js +1 -1
- package/lib/sector-focus.js +17 -3
- package/lib/separation.js +4 -2
- package/lib/settings-client.js +21 -0
- package/lib/settings-effects.js +19 -1
- package/lib/settings.js +7 -2
- package/package.json +2 -2
- package/public/demo-data.json +1 -1
- package/scripts/check-node.mjs +1 -1
- package/scripts/image_store.py +77 -21
- package/scripts/scrape.py +305 -29
- package/.next/standalone/.next/server/chunks/[root-of-the-server]__1t3rugi._.js +0 -3
- 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_0jzisgh.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/_07u8byv._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_08r_mlf._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0942v8n._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0hl_t7r._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/_0tb83dw._.js +0 -132
- 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/server/chunks/ssr/lib_0i8v-s3._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_16vay6l._.js +0 -3
- package/.next/standalone/.next/server/chunks/ssr/node_modules_1kciwn4._.js +0 -3
- package/.next/standalone/.next/static/chunks/06x3jiiz96m5t.js +0 -1
- package/.next/standalone/.next/static/chunks/08k1crid-05dt.js +0 -1
- package/.next/standalone/.next/static/chunks/0ngx9s6-zgshe.js +0 -33
- package/.next/standalone/.next/static/chunks/0q-_khndfxubv.js +0 -2
- package/.next/standalone/.next/static/chunks/1c79veqedvubt.js +0 -1
- package/.next/standalone/.next/static/chunks/1chp3nx45meg5.js +0 -44
- package/.next/standalone/.next/static/chunks/1irqj0lf-fu3d.js +0 -1
- package/.next/standalone/.next/static/chunks/1uadqylrod5_v.js +0 -1
- package/.next/standalone/.next/static/chunks/23u0zclgwpc4d.js +0 -1
- package/.next/standalone/.next/static/chunks/289ktj97di6zg.js +0 -1
- package/.next/standalone/.next/static/chunks/2b3lmf8e6uj8m.js +0 -16
- package/.next/standalone/.next/static/chunks/3npq5yd7c4dmn.js +0 -2
- package/.next/standalone/app/components/BridgeRing.js +0 -753
- package/.next/static/chunks/06x3jiiz96m5t.js +0 -1
- package/.next/static/chunks/08k1crid-05dt.js +0 -1
- package/.next/static/chunks/0ngx9s6-zgshe.js +0 -33
- package/.next/static/chunks/0q-_khndfxubv.js +0 -2
- package/.next/static/chunks/1c79veqedvubt.js +0 -1
- package/.next/static/chunks/1chp3nx45meg5.js +0 -44
- package/.next/static/chunks/1irqj0lf-fu3d.js +0 -1
- package/.next/static/chunks/1uadqylrod5_v.js +0 -1
- package/.next/static/chunks/23u0zclgwpc4d.js +0 -1
- package/.next/static/chunks/289ktj97di6zg.js +0 -1
- package/.next/static/chunks/2b3lmf8e6uj8m.js +0 -16
- package/.next/static/chunks/3npq5yd7c4dmn.js +0 -2
- /package/.next/standalone/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_buildManifest.js +0 -0
- /package/.next/standalone/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_clientMiddlewareManifest.js +0 -0
- /package/.next/standalone/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_ssgManifest.js +0 -0
- /package/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_buildManifest.js +0 -0
- /package/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_clientMiddlewareManifest.js +0 -0
- /package/.next/static/{qqqcxvzBUizkECrT4swyK → WseE_5HuU-1q0ywC9CP3U}/_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,23 +189,35 @@ 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
210
|
id: c.id, name: c.name, tier: c.tier, degree: 1,
|
|
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
|
}))];
|
|
220
|
+
const nodeById = new Map(nodes.map(n => [n.id, n]));
|
|
203
221
|
|
|
204
222
|
const links = connections.map(c => ({ source: CENTER_ID, target: c.id }));
|
|
205
223
|
|
|
@@ -212,53 +230,100 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
212
230
|
return Math.max(3, Math.min(12, (d.power_score || 1) * 1.3));
|
|
213
231
|
};
|
|
214
232
|
|
|
233
|
+
// A phone. Judged by the window, as the page judges it, not by the graph's
|
|
234
|
+
// own box: that narrowed whenever a panel opened on a laptop.
|
|
235
|
+
const isMobileGraph = window.innerWidth < 768;
|
|
236
|
+
|
|
215
237
|
const g = svg.append('g');
|
|
238
|
+
// Where the view is going while a transition takes it there. A resize part-way
|
|
239
|
+
// through re-fits the destination, and a rebuild starts from it, rather than
|
|
240
|
+
// from a point along the way.
|
|
241
|
+
let heading = null;
|
|
216
242
|
const zoomBehavior = d3.zoom().scaleExtent([0.3, 4]).on('zoom', (e) => {
|
|
217
243
|
g.attr('transform', e.transform);
|
|
218
|
-
|
|
244
|
+
viewRef.current = { transform: heading ?? e.transform, size: box };
|
|
245
|
+
placeRing();
|
|
219
246
|
});
|
|
220
247
|
svg.call(zoomBehavior);
|
|
221
248
|
|
|
222
|
-
|
|
249
|
+
const moveView = (to, duration) => {
|
|
250
|
+
if (reducedMotion()) {
|
|
251
|
+
heading = null;
|
|
252
|
+
svg.interrupt().call(zoomBehavior.transform, to);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
heading = to;
|
|
256
|
+
viewRef.current = { transform: to, size: box };
|
|
257
|
+
// Stopped before its first frame, by a wheel or a click at that moment, a
|
|
258
|
+
// slide reports "cancel" rather than "interrupt". Missing that left heading
|
|
259
|
+
// set, and every zoom after it was saved as this destination. A scroll
|
|
260
|
+
// already under way doesn't stop a slide, which lands here all the same.
|
|
261
|
+
svg.transition().duration(duration).call(zoomBehavior.transform, to)
|
|
262
|
+
.on('end interrupt cancel', () => { if (heading === to) heading = null; });
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
// The selection ring, tied to the selected person's dot. It is drawn again
|
|
266
|
+
// after any rebuild, and hidden while they are filtered out.
|
|
267
|
+
const pulse = ring.firstChild;
|
|
268
|
+
let ringNode = null;
|
|
269
|
+
const placeRing = () => {
|
|
270
|
+
if (!ringNode) return;
|
|
271
|
+
const t = d3.zoomTransform(svg.node());
|
|
272
|
+
ring.style.transform = `translate(${t.applyX(ringNode.x)}px, ${t.applyY(ringNode.y)}px) scale(${t.k})`;
|
|
273
|
+
};
|
|
274
|
+
const select = (id) => {
|
|
275
|
+
ringNode = id != null && id !== CENTER_ID ? nodeById.get(id) ?? null : null;
|
|
276
|
+
ring.style.display = ringNode ? 'block' : 'none';
|
|
277
|
+
if (!ringNode) return;
|
|
278
|
+
// The ring the <svg> used to draw: 8 past the dot, 2.5 wide.
|
|
279
|
+
const r = nodeRadius(ringNode) + 8;
|
|
280
|
+
pulse.style.width = pulse.style.height = `${2 * r + 2.5}px`;
|
|
281
|
+
pulse.style.margin = `${-(r + 1.25)}px`;
|
|
282
|
+
pulse.style.borderColor = tierColors[ringNode.tier] || '#FFD700';
|
|
283
|
+
placeRing();
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// A new size: the view slides by half the change, at the same zoom.
|
|
287
|
+
const resize = (next) => {
|
|
288
|
+
const t = recentre(heading ?? d3.zoomTransform(svg.node()), box, next);
|
|
289
|
+
box = next;
|
|
290
|
+
moveView(d3.zoomIdentity.translate(t.x, t.y).scale(t.k), 250);
|
|
291
|
+
};
|
|
292
|
+
|
|
293
|
+
// Restore the previous view if there is one (filter changes); otherwise put
|
|
294
|
+
// you in the middle of the box. A phone starts zoomed out to fit, since the
|
|
295
|
+
// outer tiers reach past its edges.
|
|
223
296
|
if (savedTransform) {
|
|
224
|
-
svg.call(zoomBehavior.transform, savedTransform);
|
|
297
|
+
svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(savedTransform.x, savedTransform.y).scale(savedTransform.k));
|
|
298
|
+
} else {
|
|
299
|
+
const maxTierR = 520;
|
|
300
|
+
const fitScale = isMobileGraph && box.width > 0 && box.height > 0
|
|
301
|
+
? Math.min(box.width / (maxTierR * 2.4), box.height / (maxTierR * 2.4)) : 1;
|
|
302
|
+
svg.call(zoomBehavior.transform, d3.zoomIdentity.translate(box.width / 2, box.height / 2).scale(fitScale));
|
|
225
303
|
}
|
|
226
304
|
|
|
227
305
|
// Expose focusNode for search — zooms to a specific node
|
|
228
306
|
if (focusNodeRef) {
|
|
229
307
|
focusNodeRef.current = (nodeId) => {
|
|
230
|
-
const target =
|
|
308
|
+
const target = nodeById.get(nodeId);
|
|
231
309
|
if (!target) return;
|
|
232
310
|
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');
|
|
311
|
+
moveView(d3.zoomIdentity.translate(box.width / 2 - target.x * scale, box.height / 2 - target.y * scale).scale(scale), 750);
|
|
312
|
+
select(nodeId);
|
|
247
313
|
};
|
|
248
314
|
}
|
|
249
315
|
|
|
250
316
|
// On mobile: pre-compute static positions (no simulation jitter/reset on resize)
|
|
251
|
-
const isMobileGraph = width < 768;
|
|
252
317
|
if (isMobileGraph) {
|
|
253
318
|
const tierGroups = {};
|
|
254
319
|
nodes.forEach(n => { const t = n.tier || 'D'; if (!tierGroups[t]) tierGroups[t] = []; tierGroups[t].push(n); });
|
|
255
320
|
Object.entries(tierGroups).forEach(([tier, group]) => {
|
|
256
321
|
group.forEach((n, i) => {
|
|
257
|
-
if (n.id === CENTER_ID) { n.x =
|
|
322
|
+
if (n.id === CENTER_ID) { n.x = 0; n.y = 0; n.fx = n.x; n.fy = n.y; return; }
|
|
258
323
|
const r = tierRadius(tier);
|
|
259
324
|
const angle = (i / group.length) * Math.PI * 2 - Math.PI / 2;
|
|
260
|
-
n.x =
|
|
261
|
-
n.y =
|
|
325
|
+
n.x = r * Math.cos(angle);
|
|
326
|
+
n.y = r * Math.sin(angle);
|
|
262
327
|
n.fx = n.x; n.fy = n.y; // Fixed positions — no physics
|
|
263
328
|
});
|
|
264
329
|
});
|
|
@@ -267,12 +332,15 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
267
332
|
const simulation = d3.forceSimulation(nodes)
|
|
268
333
|
.force('link', d3.forceLink(links).id(d => d.id).distance(d => tierRadius(d.target.tier || 'D')).strength(isMobileGraph ? 0 : 0.1))
|
|
269
334
|
.force('charge', d3.forceManyBody().strength(isMobileGraph ? 0 : (d => d.id === CENTER_ID ? -300 : -15)))
|
|
270
|
-
.force('center', isMobileGraph ? null : d3.forceCenter(
|
|
335
|
+
.force('center', isMobileGraph ? null : d3.forceCenter(0, 0))
|
|
271
336
|
.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),
|
|
337
|
+
.force('radial', isMobileGraph ? null : d3.forceRadial(d => d.id === CENTER_ID ? 0 : tierRadius(d.tier), 0, 0).strength(0.3));
|
|
273
338
|
|
|
339
|
+
// forceLink has already swapped each link's ids for the nodes themselves, so
|
|
340
|
+
// the colour is one step away. It used to search every node for every link:
|
|
341
|
+
// about 1.8 s at 30,000 people.
|
|
274
342
|
const link = g.append('g').selectAll('line').data(links).join('line')
|
|
275
|
-
.attr('stroke', d =>
|
|
343
|
+
.attr('stroke', d => tierColors[d.target.tier] || '#333')
|
|
276
344
|
.attr('stroke-opacity', 0.15).attr('stroke-width', 0.5);
|
|
277
345
|
|
|
278
346
|
// Catalyst outer glow rings (rendered behind the nodes)
|
|
@@ -309,16 +377,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
309
377
|
.style('cursor', 'pointer')
|
|
310
378
|
.on('click', (event, d) => {
|
|
311
379
|
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');
|
|
380
|
+
select(d.id);
|
|
322
381
|
onSelect(d);
|
|
323
382
|
});
|
|
324
383
|
|
|
@@ -331,7 +390,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
331
390
|
.style('z-index', 1001).style('transition', 'opacity 0.15s');
|
|
332
391
|
|
|
333
392
|
node.on('mouseover', function (event, d) {
|
|
334
|
-
|
|
393
|
+
easeRadius(this, nodeRadius(d) * 1.5);
|
|
335
394
|
if (d.profile_image_url && d.id !== CENTER_ID) {
|
|
336
395
|
const size = Math.max(48, nodeRadius(d) * 5);
|
|
337
396
|
photoTooltip.style('opacity', 1);
|
|
@@ -358,7 +417,7 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
358
417
|
tooltip.style('left', (event.clientX + 12) + 'px').style('top', (event.clientY - 10) + 'px');
|
|
359
418
|
}
|
|
360
419
|
}).on('mouseout', function (event, d) {
|
|
361
|
-
|
|
420
|
+
easeRadius(this, nodeRadius(d));
|
|
362
421
|
tooltip.style('opacity', 0);
|
|
363
422
|
photoTooltip.style('opacity', 0);
|
|
364
423
|
});
|
|
@@ -386,654 +445,10 @@ function renderNetworkMode(svg, width, height, connections, onSelect, tierColors
|
|
|
386
445
|
node.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
387
446
|
catalystRings.attr('cx', d => d.x).attr('cy', d => d.y);
|
|
388
447
|
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;
|
|
448
|
+
placeRing();
|
|
510
449
|
});
|
|
511
|
-
svg.call(zoomBehavior);
|
|
512
450
|
|
|
513
|
-
|
|
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
|
-
}
|
|
1025
|
-
});
|
|
1026
|
-
|
|
1027
|
-
// --- Auto-zoom to fit the layout (only on first render, not when restoring zoom) ---
|
|
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;
|
|
451
|
+
return { simulation, resize, select };
|
|
1037
452
|
}
|
|
1038
453
|
|
|
1039
454
|
function setupDrag(node, simulation, fixedId) {
|
|
@@ -1042,6 +457,6 @@ function setupDrag(node, simulation, fixedId) {
|
|
|
1042
457
|
.on('drag', (event, d) => { d.fx = event.x; d.fy = event.y; })
|
|
1043
458
|
.on('end', (event, d) => {
|
|
1044
459
|
if (!event.active) simulation.alphaTarget(0);
|
|
1045
|
-
if (d.id !== fixedId
|
|
460
|
+
if (d.id !== fixedId) { d.fx = null; d.fy = null; }
|
|
1046
461
|
}));
|
|
1047
462
|
}
|