From 9d13645e5a1e40cf5ec5ccd0da5d5396409a9237 Mon Sep 17 00:00:00 2001 From: SHAcollision Date: Sat, 4 Jul 2026 17:25:40 +0200 Subject: [PATCH 1/2] experiment: graph explorer (/graph page and feed graph layout) --- cypress/e2e/graph-public.cy.ts | 19 + cypress/e2e/graph.cy.ts | 26 + docs/graph-explorer-experiment.md | 37 + messages/ar.json | 82 +- messages/de.json | 82 +- messages/en.json | 85 +- messages/es.json | 82 +- messages/fr.json | 82 +- messages/it.json | 82 +- messages/ja.json | 82 +- messages/pt-BR.json | 82 +- messages/zh.json | 82 +- package-lock.json | 464 +++++++++- package.json | 3 + src/app/graph/page.tsx | 26 + src/app/routes.ts | 4 +- .../CanvasAnchoredPopover.test.tsx | 18 + .../CanvasAnchoredPopover.tsx | 71 ++ src/components/molecules/Fab/Fab.tsx | 10 +- .../Filters/FilterLayout/FilterLayout.tsx | 14 +- .../FollowButton.test.tsx} | 29 +- .../FollowButton.test.tsx.snap} | 6 +- .../molecules/FollowButton/FollowButton.tsx | 57 ++ .../GraphBreadcrumbs/GraphBreadcrumbs.tsx | 58 ++ .../molecules/GraphSearch/GraphSearch.tsx | 125 +++ .../GraphTimeMachine/GraphTimeMachine.tsx | 140 +++ .../molecules/Header/Header.test.tsx | 2 +- .../molecules/Header/Header.test.tsx.snap | 171 ++++ src/components/molecules/Header/Header.tsx | 9 +- .../MobileFooter/MobileFooter.test.tsx | 2 + .../MobileFooter/MobileFooter.test.tsx.snap | 147 +++ .../molecules/MobileFooter/MobileFooter.tsx | 8 +- .../SocialGraphControls.test.tsx | 44 + .../SocialGraphControls.tsx | 110 +++ .../SocialGraphControls.types.ts | 18 + .../SocialGraphLegend.test.tsx | 55 ++ .../SocialGraphLegend/SocialGraphLegend.tsx | 162 ++++ .../UserInfoPopoverFollowButton.tsx | 53 +- .../ProfileHoverCard.test.tsx | 45 + .../ProfileHoverCard/ProfileHoverCard.tsx | 64 ++ .../SearchInput/SearchInput.test.tsx | 30 + .../organisms/SearchInput/SearchInput.tsx | 22 + .../SocialGraph/SocialGraph.test.tsx | 198 ++++ .../SocialGraph/SocialGraph.theme.test.ts | 52 ++ .../SocialGraph/SocialGraph.theme.ts | 118 +++ .../organisms/SocialGraph/SocialGraph.tsx | 856 ++++++++++++++++++ .../SocialGraph/SocialGraph.types.ts | 48 + .../SocialGraphNodePanel.test.tsx | 97 ++ .../SocialGraphNodePanel.tsx | 237 +++++ .../SocialGraphNodePanel.types.ts | 31 + .../TimelineFeed.collection.test.tsx | 1 + .../Feed/TimelineFeed/TimelineFeed.test.tsx | 12 + .../TimelineFeedContent.test.tsx | 2 + .../TimelineFeedContent.tsx | 12 +- .../StreamGraphPosts.test.tsx | 69 ++ .../StreamGraphPosts/StreamGraphPosts.tsx | 263 ++++++ src/components/templates/Graph/Graph.tsx | 553 +++++++++++ src/config/theme.ts | 27 + src/core/application/graph/graph.test.ts | 100 ++ src/core/application/graph/graph.ts | 65 ++ src/core/controllers/graph/graph.ts | 27 + src/core/services/nexus/graph/graph.api.ts | 27 + src/core/services/nexus/graph/graph.test.ts | 76 ++ src/core/services/nexus/graph/graph.ts | 37 + src/core/services/nexus/graph/graph.types.ts | 76 ++ src/core/stores/graph/graph.actions.ts | 41 + src/core/stores/graph/graph.store.test.ts | 58 ++ src/core/stores/graph/graph.store.ts | 44 + src/core/stores/graph/graph.types.ts | 51 ++ src/core/stores/home/home.types.ts | 1 + src/core/stores/persistedKeys.ts | 2 + .../useFeedLayoutResolution.ts | 8 +- src/hooks/useGraphCore/useGraphCore.ts | 315 +++++++ .../useProfileMenuActions.constants.ts | 1 + .../useProfileMenuActions.test.tsx | 5 + .../useProfileMenuActions.tsx | 16 +- .../useSocialGraph/useSocialGraph.test.tsx | 210 +++++ src/hooks/useSocialGraph/useSocialGraph.tsx | 244 +++++ .../useSocialGraph/useSocialGraph.types.ts | 75 ++ .../useSocialGraph.utils.test.ts | 135 +++ .../useSocialGraph/useSocialGraph.utils.ts | 362 ++++++++ .../useSocialGraph.viewmodel.test.ts | 190 ++++ .../useStreamGraph/useStreamGraph.test.tsx | 58 ++ src/hooks/useStreamGraph/useStreamGraph.ts | 207 +++++ .../useStreamGraph.utils.test.ts | 139 +++ .../useStreamGraph/useStreamGraph.utils.ts | 150 +++ src/libs/utils/utils.test.ts | 41 +- src/libs/utils/utils.ts | 22 + 88 files changed, 8040 insertions(+), 109 deletions(-) create mode 100644 cypress/e2e/graph-public.cy.ts create mode 100644 cypress/e2e/graph.cy.ts create mode 100644 docs/graph-explorer-experiment.md create mode 100644 src/app/graph/page.tsx create mode 100644 src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx create mode 100644 src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx rename src/components/molecules/{UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx => FollowButton/FollowButton.test.tsx} (55%) rename src/components/molecules/{UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap => FollowButton/FollowButton.test.tsx.snap} (93%) create mode 100644 src/components/molecules/FollowButton/FollowButton.tsx create mode 100644 src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx create mode 100644 src/components/molecules/GraphSearch/GraphSearch.tsx create mode 100644 src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx create mode 100644 src/components/molecules/SocialGraphControls/SocialGraphControls.test.tsx create mode 100644 src/components/molecules/SocialGraphControls/SocialGraphControls.tsx create mode 100644 src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts create mode 100644 src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx create mode 100644 src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx create mode 100644 src/components/organisms/ProfileHoverCard/ProfileHoverCard.test.tsx create mode 100644 src/components/organisms/ProfileHoverCard/ProfileHoverCard.tsx create mode 100644 src/components/organisms/SocialGraph/SocialGraph.test.tsx create mode 100644 src/components/organisms/SocialGraph/SocialGraph.theme.test.ts create mode 100644 src/components/organisms/SocialGraph/SocialGraph.theme.ts create mode 100644 src/components/organisms/SocialGraph/SocialGraph.tsx create mode 100644 src/components/organisms/SocialGraph/SocialGraph.types.ts create mode 100644 src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx create mode 100644 src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx create mode 100644 src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts create mode 100644 src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.test.tsx create mode 100644 src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx create mode 100644 src/components/templates/Graph/Graph.tsx create mode 100644 src/core/application/graph/graph.test.ts create mode 100644 src/core/application/graph/graph.ts create mode 100644 src/core/controllers/graph/graph.ts create mode 100644 src/core/services/nexus/graph/graph.api.ts create mode 100644 src/core/services/nexus/graph/graph.test.ts create mode 100644 src/core/services/nexus/graph/graph.ts create mode 100644 src/core/services/nexus/graph/graph.types.ts create mode 100644 src/core/stores/graph/graph.actions.ts create mode 100644 src/core/stores/graph/graph.store.test.ts create mode 100644 src/core/stores/graph/graph.store.ts create mode 100644 src/core/stores/graph/graph.types.ts create mode 100644 src/hooks/useGraphCore/useGraphCore.ts create mode 100644 src/hooks/useSocialGraph/useSocialGraph.test.tsx create mode 100644 src/hooks/useSocialGraph/useSocialGraph.tsx create mode 100644 src/hooks/useSocialGraph/useSocialGraph.types.ts create mode 100644 src/hooks/useSocialGraph/useSocialGraph.utils.test.ts create mode 100644 src/hooks/useSocialGraph/useSocialGraph.utils.ts create mode 100644 src/hooks/useSocialGraph/useSocialGraph.viewmodel.test.ts create mode 100644 src/hooks/useStreamGraph/useStreamGraph.test.tsx create mode 100644 src/hooks/useStreamGraph/useStreamGraph.ts create mode 100644 src/hooks/useStreamGraph/useStreamGraph.utils.test.ts create mode 100644 src/hooks/useStreamGraph/useStreamGraph.utils.ts diff --git a/cypress/e2e/graph-public.cy.ts b/cypress/e2e/graph-public.cy.ts new file mode 100644 index 0000000000..08a0469a05 --- /dev/null +++ b/cypress/e2e/graph-public.cy.ts @@ -0,0 +1,19 @@ +// Public deep-link exploration: /graph is an explore route, so a signed-out +// visitor can inspect any user's neighborhood via ?user=. +describe('graph explorer (public deep link)', () => { + it('loads a neighborhood from nexus and renders the canvas', () => { + // Any pubky known to the connected Nexus; overridable per environment + const pubky = Cypress.env('GRAPH_PUBKY') ?? 'gujx6qd8ksydh1makdphd3bxu351d9b8waqka8hfg6q7hnqkxexo'; + + cy.intercept('GET', '**/v0/graph/user/**').as('neighborhood'); + cy.visit(`/graph?user=${pubky}`); + + cy.get('[data-cy="graph-page"]').should('exist'); + cy.wait('@neighborhood', { timeout: 30000 }).its('response.statusCode').should('eq', 200); + + // The canvas mounts once data is in + cy.get('[data-cy="social-graph"] canvas', { timeout: 30000 }).should('exist'); + cy.get('[data-cy="graph-legend"]').should('be.visible'); + cy.get('[data-cy="graph-controls"]').should('be.visible'); + }); +}); diff --git a/cypress/e2e/graph.cy.ts b/cypress/e2e/graph.cy.ts new file mode 100644 index 0000000000..a0d20dfc97 --- /dev/null +++ b/cypress/e2e/graph.cy.ts @@ -0,0 +1,26 @@ +import { slowCypressDown } from 'cypress-slow-down'; +import { BackupType } from '../support/types/enums'; + +describe('graph explorer', () => { + before(() => { + slowCypressDown(); + cy.deleteDownloadsFolder(); + }); + + it('renders the graph page shell with legend and controls', () => { + cy.onboardAsNewUser('Graph Explorer', 'I map the social universe', [BackupType.RecoveryPhraseWithoutConfirmation]); + + cy.visit('/graph'); + cy.get('[data-cy="graph-page"]').should('exist'); + cy.get('[data-cy="graph-legend"]').should('be.visible'); + cy.get('[data-cy="graph-controls"]').should('be.visible'); + + // A fresh account has no follows: the empty state invites growing the graph + cy.contains('Nothing to explore yet', { timeout: 20000 }).should('be.visible'); + + // Legend rows double as class toggles + cy.get('[data-cy="graph-legend-post"]').should('have.attr', 'aria-pressed', 'true'); + cy.get('[data-cy="graph-legend-post"]').click(); + cy.get('[data-cy="graph-legend-post"]').should('have.attr', 'aria-pressed', 'false'); + }); +}); diff --git a/docs/graph-explorer-experiment.md b/docs/graph-explorer-experiment.md new file mode 100644 index 0000000000..0b228e7fd2 --- /dev/null +++ b/docs/graph-explorer-experiment.md @@ -0,0 +1,37 @@ +# Graph Explorer experiment (`experiment/graph-viz`) + +An experimental social-graph exploration feature spanning two branches: + +- **franky** `experiment/graph-viz`: the `/graph` explorer page + a "Graph" feed layout. +- **pubky-nexus** `experiment/graph-viz`: the graph API it talks to. + +## What you get + +- **`/graph`** (top-nav Waypoints icon, or `/graph?user=` deep link, public explore route): an ego-centric canvas of users, posts, and tags. Click = inspector panel (real post cards, reply-in-place, follow, social proof). Double-click = expand that node's neighborhood. Hover a user = profile card. Legend rows filter classes; controls hold declutter, communities (Louvain), the time machine, and physics pause/pinning. "How am I connected?" traces the shortest follow path with particles. +- **Edge colors carry data**: follows touching the focused user keep relationship colors (the legend palette); follows between neighbors fade by age (fresh = warm bright, old = gray); with communities on, intra-community edges take the community tint and bridges between communities stay bright neutral. Tag edges use their label's color, and their count chips are tap targets. The legend teaches this: a gradient row for follow age, plus community/bridge rows whenever communities mode is on, and hovering any of them spotlights the matching edges on the canvas. +- **Feed "Graph" layout** (left sidebar layout switcher on Home/Custom/Search, desktop): renders the current stream as a constellation. Authors carry their posts, replies/reposts draw lineage chains, the stream's hottest tags become hubs. "Merge more" grows the graph a page at a time; the time machine replays the feed assembling. + +## Backend endpoints (nexus) + +- `GET /v0/graph/{kind}/{id}` with `kind` in `user|post|tag`; params `depth` (1..2, user centers only), `limit` (1..50, default 30), `kinds` csv filter. Node ids are prefixed (`user:{pubky}`, `post:{author}:{post_id}`, `tag:{label}`); FOLLOWS/TAGGED edges carry `indexed_at`. +- `GET /v0/graph/path/{from}/{to}`: undirected shortest FOLLOWS path, max 6 hops, nodes path-ordered. +- `nexusd db reindex`: rebuilds the Redis index from the Neo4j graph (flushes Redis first; connection settings from `config.toml` in the config dir). You need this once after restoring a graph backup, or feed streams come back empty. + +## Running it locally against a production clone + +1. Check out both `experiment/graph-viz` branches. +2. Restore a Neo4j backup into the compose volume (`docker/.database/neo4j/data`; stop the container first, `chown -R 7474:7474` after extracting). Set the backup's password in `~/.pubky-nexus/config.toml` AND `~/.pubky-nexus/migrations/config.toml`. +3. `cargo run -p nexusd -- db reindex` (populates Redis), then `cargo run -p nexusd -- api` (port 8080). +4. In franky `.env`: `NEXT_PUBLIC_NEXUS_URL=http://localhost:8080` and make sure `NEXT_PUBLIC_PKARR_RELAYS` is the JSON-array form. +5. `npm run dev` and open `/graph?user=`. + +For the mock dataset instead: `cargo run -p nexusd -- db mock`, then use the fixture user `4snwyct86m383rsduhw5xgcxpw7c63j3pq8x4ycqikxgik8y64ro`. + +## The two-minute demo + +Sign in, switch Home to Following, flip the layout to Graph, hit the time machine's play button and watch the week assemble. Then open `/graph`, double-click a friend, hover people, select a post and reply to it from the panel, and hit "How am I connected?" on a stranger. + +## Tests + +- Nexus: `cargo nextest run -p nexus-webapi` (needs the mock dataset loaded; 13 graph tests among 508). +- Franky: `npm test` (synthesizer, hooks, canvas, chrome all covered); `cypress/e2e/graph-public.cy.ts` runs against a live stack. diff --git a/messages/ar.json b/messages/ar.json index aff7ccd1b7..b52c36b327 100644 --- a/messages/ar.json +++ b/messages/ar.json @@ -160,7 +160,8 @@ "feed": "فيد", "search": "بحث", "collections": "المجموعات", - "new": "جديد" + "new": "جديد", + "graph": "الرسم البياني" }, "settings": { "title": "الاعدادات", @@ -584,7 +585,8 @@ "mute": "كتم {username}", "unmute": "الغاء كتم {username}", "viewProfile": "عرض ملف {name} الشخصي", - "thisIsYou": "هذا انت" + "thisIsYou": "هذا انت", + "openInGraph": "فتح في الرسم البياني" }, "friendsWithYou": "(تتابعون بعضكم البعض)", "empty": { @@ -1152,7 +1154,8 @@ "title": "التخطيط", "columns": "اعمدة", "wide": "عريض", - "visual": "مرئي" + "visual": "مرئي", + "graph": "رسم بياني" }, "content": { "title": "المحتوى", @@ -1449,5 +1452,78 @@ "moderation": { "postContentModerated": "تم حظر محتوى المنشور.", "collectionContentModerated": "تم حظر محتوى المجموعة." + }, + "graph": { + "title": "الرسم البياني", + "legend": { + "title": "مفتاح الرموز", + "self": "أنت", + "friend": "صديق", + "following": "تتابعه", + "follower": "متابع", + "extended": "موسع", + "user": "مستخدم", + "post": "منشور", + "tag": "وسم", + "sameCommunity": "نفس المجتمع", + "bridge": "جسر", + "followAge": "عمر المتابعة", + "old": "قديم", + "new": "جديد" + }, + "controls": { + "zoomIn": "تكبير", + "zoomOut": "تصغير", + "fit": "ملاءمة العرض", + "recenter": "إعادة التوسيط", + "showPosts": "عرض المنشورات", + "showTags": "عرض الوسوم", + "declutter": "ترتيب", + "communities": "مجتمعات", + "timeMachine": "آلة الزمن", + "pausePhysics": "إيقاف الفيزياء", + "resumePhysics": "استئناف الفيزياء", + "releasePins": "تحرير العقد المثبتة" + }, + "panel": { + "close": "إغلاق", + "expand": "توسيع", + "expanded": "موسع", + "focus": "تركيز", + "openProfile": "الملف الشخصي", + "openPost": "فتح المنشور", + "emptyPost": "لا يوجد محتوى نصي", + "postBy": "منشور من {name}", + "searchTag": "بحث", + "tagUsage": "استُخدم {count, plural, one {مرة واحدة} two {مرتين} few {# مرات} other {# مرة}}", + "reply": "رد", + "tracePath": "كيف أنا متصل؟", + "followedBy": "يتابعه {count} ممن تتابعهم" + }, + "states": { + "error": "تعذر تحميل الرسم البياني.", + "retry": "إعادة المحاولة", + "empty": "لا شيء لاستكشافه بعد. تابع أشخاصًا لتنمية الرسم البياني الخاص بك.", + "emptyCta": "ابحث عن أشخاص لمتابعتهم", + "noUser": "سجّل الدخول أو ابحث عن مستخدم أو وسم لاستكشاف الرسم البياني.", + "tooManyNodes": "الرسم البياني ممتلئ: تم إخفاء العقد البعيدة.", + "expandError": "تعذر توسيع هذه العقدة.", + "noPath": "لم يُعثر على مسار متابعة خلال 6 قفزات.", + "autoDeclutter": "رسم بياني كثيف: الترتيب مفعّل. يمكن تغييره من عناصر التحكم." + }, + "search": { + "placeholder": "ابحث عن مستخدمين أو وسوم" + }, + "time": { + "play": "تشغيل", + "pause": "إيقاف مؤقت", + "scrub": "التنقل عبر الزمن", + "now": "الآن", + "close": "إغلاق آلة الزمن" + }, + "stream": { + "mergeMore": "دمج المزيد", + "empty": "لا يوجد شيء في هذا التدفق بعد." + } } } diff --git a/messages/de.json b/messages/de.json index 131d453eae..7acb9725bb 100644 --- a/messages/de.json +++ b/messages/de.json @@ -160,7 +160,8 @@ "feed": "Feed", "search": "Suchen", "collections": "Sammlungen", - "new": "Neu" + "new": "Neu", + "graph": "Graph" }, "settings": { "title": "Einstellungen", @@ -584,7 +585,8 @@ "mute": "{username} stummschalten", "unmute": "Stummschaltung von {username} aufheben", "viewProfile": "Profil von {name} ansehen", - "thisIsYou": "Das sind Sie" + "thisIsYou": "Das sind Sie", + "openInGraph": "Im Graphen öffnen" }, "friendsWithYou": "(ihr folgt euch gegenseitig)", "empty": { @@ -1158,7 +1160,8 @@ "title": "Layout", "columns": "Spalten", "wide": "Breit", - "visual": "Visuell" + "visual": "Visuell", + "graph": "Graph" }, "content": { "title": "Inhalt", @@ -1455,5 +1458,78 @@ "moderation": { "postContentModerated": "Beitragsinhalt zensiert.", "collectionContentModerated": "Sammlungsinhalt zensiert." + }, + "graph": { + "title": "Graph", + "legend": { + "title": "Legende", + "self": "Du", + "friend": "Freund", + "following": "Folge ich", + "follower": "Follower", + "extended": "Erweitert", + "user": "Nutzer", + "post": "Beitrag", + "tag": "Tag", + "sameCommunity": "Gleiche Community", + "bridge": "Brücke", + "followAge": "Follow-Alter", + "old": "alt", + "new": "neu" + }, + "controls": { + "zoomIn": "Vergrößern", + "zoomOut": "Verkleinern", + "fit": "Ansicht anpassen", + "recenter": "Zentrieren", + "showPosts": "Beiträge anzeigen", + "showTags": "Tags anzeigen", + "declutter": "Aufräumen", + "communities": "Communities", + "timeMachine": "Zeitmaschine", + "pausePhysics": "Physik pausieren", + "resumePhysics": "Physik fortsetzen", + "releasePins": "Fixierte Knoten lösen" + }, + "panel": { + "close": "Schließen", + "expand": "Erweitern", + "expanded": "Erweitert", + "focus": "Fokussieren", + "openProfile": "Profil", + "openPost": "Beitrag öffnen", + "emptyPost": "Kein Textinhalt", + "postBy": "Beitrag von {name}", + "searchTag": "Suchen", + "tagUsage": "{count, plural, one {# Mal} other {# Mal}} verwendet", + "reply": "Antworten", + "tracePath": "Wie bin ich verbunden?", + "followedBy": "gefolgt von {count}, denen du folgst" + }, + "states": { + "error": "Der Graph konnte nicht geladen werden.", + "retry": "Erneut versuchen", + "empty": "Noch nichts zu erkunden. Folge Leuten, um deinen Graphen wachsen zu lassen.", + "emptyCta": "Leute zum Folgen finden", + "noUser": "Melde dich an oder suche nach einem Nutzer oder Tag, um den Graphen zu erkunden.", + "tooManyNodes": "Graph ist voll: entfernte Knoten wurden ausgeblendet.", + "expandError": "Dieser Knoten konnte nicht erweitert werden.", + "noPath": "Kein Folgepfad innerhalb von 6 Schritten gefunden.", + "autoDeclutter": "Dichter Graph: Aufräumen ist aktiv. In den Steuerungen umschaltbar." + }, + "search": { + "placeholder": "Nutzer oder Tags suchen" + }, + "time": { + "play": "Abspielen", + "pause": "Pause", + "scrub": "Durch die Zeit scrollen", + "now": "Jetzt", + "close": "Zeitmaschine schließen" + }, + "stream": { + "mergeMore": "Mehr laden", + "empty": "Noch nichts in diesem Stream." + } } } diff --git a/messages/en.json b/messages/en.json index 8163017c93..5c58f4a779 100644 --- a/messages/en.json +++ b/messages/en.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Search", "collections": "Collections", - "new": "New" + "new": "New", + "graph": "Graph" }, "settings": { "title": "Settings", @@ -473,11 +474,8 @@ "createInBrowser": "Create keys in browser", "continueWithRing": "Continue with Pubky Ring", "inviteCodeApplied": "Invite code applied", - "invalidInviteCode": "Invalid invite code", - "usedInviteCode": "Invite code already used", - "verificationFailed": "Couldn't verify invite code" }, "createProfile": { @@ -585,7 +583,8 @@ "mute": "Mute {username}", "unmute": "Unmute {username}", "viewProfile": "View {name}'s profile", - "thisIsYou": "This is you" + "thisIsYou": "This is you", + "openInGraph": "Open in graph" }, "friendsWithYou": "(you follow each other)", "empty": { @@ -1159,7 +1158,8 @@ "title": "Layout", "columns": "Columns", "wide": "Wide", - "visual": "Visual" + "visual": "Visual", + "graph": "Graph" }, "content": { "title": "Content", @@ -1456,5 +1456,78 @@ "moderation": { "postContentModerated": "Post content moderated.", "collectionContentModerated": "Collection content moderated." + }, + "graph": { + "title": "Graph", + "legend": { + "title": "Legend", + "self": "You", + "friend": "Friend", + "following": "Following", + "follower": "Follower", + "extended": "Extended", + "user": "User", + "post": "Post", + "tag": "Tag", + "sameCommunity": "Same community", + "bridge": "Bridge", + "followAge": "Follow age", + "old": "old", + "new": "new" + }, + "controls": { + "zoomIn": "Zoom in", + "zoomOut": "Zoom out", + "fit": "Fit view", + "recenter": "Re-center", + "showPosts": "Show posts", + "showTags": "Show tags", + "declutter": "Declutter", + "communities": "Communities", + "timeMachine": "Time machine", + "pausePhysics": "Pause physics", + "resumePhysics": "Resume physics", + "releasePins": "Release pinned nodes" + }, + "panel": { + "close": "Close", + "expand": "Expand", + "expanded": "Expanded", + "focus": "Focus", + "openProfile": "Profile", + "openPost": "Open post", + "emptyPost": "No text content", + "postBy": "Post by {name}", + "searchTag": "Search", + "tagUsage": "used {count, plural, one {# time} other {# times}}", + "reply": "Reply", + "tracePath": "How am I connected?", + "followedBy": "followed by {count} you follow" + }, + "states": { + "error": "Could not load the graph.", + "retry": "Retry", + "empty": "Nothing to explore yet. Follow people to grow your graph.", + "emptyCta": "Find people to follow", + "noUser": "Sign in, or search for a user or tag to explore the graph.", + "tooManyNodes": "Graph is full: distant nodes were hidden.", + "expandError": "Could not expand this node.", + "noPath": "No follow path found within 6 hops.", + "autoDeclutter": "Dense graph: declutter is on. Toggle it in the controls." + }, + "search": { + "placeholder": "Search users or tags" + }, + "time": { + "play": "Play", + "pause": "Pause", + "scrub": "Scrub through time", + "now": "Now", + "close": "Close time machine" + }, + "stream": { + "mergeMore": "Merge more", + "empty": "Nothing in this stream yet." + } } } diff --git a/messages/es.json b/messages/es.json index 55741bab91..eeecda7bd6 100644 --- a/messages/es.json +++ b/messages/es.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Buscar", "collections": "Colecciones", - "new": "Nuevo" + "new": "Nuevo", + "graph": "Grafo" }, "settings": { "title": "Configuración", @@ -584,7 +585,8 @@ "mute": "Silenciar a {username}", "unmute": "Desilenciar a {username}", "viewProfile": "Ver perfil de {name}", - "thisIsYou": "Eres tu" + "thisIsYou": "Eres tu", + "openInGraph": "Abrir en el grafo" }, "friendsWithYou": "(se siguen mutuamente)", "empty": { @@ -1158,7 +1160,8 @@ "title": "Diseño", "columns": "Columnas", "wide": "Ancho", - "visual": "Visual" + "visual": "Visual", + "graph": "Grafo" }, "content": { "title": "Contenido", @@ -1457,5 +1460,78 @@ "moderation": { "postContentModerated": "Contenido de la publicación censurado.", "collectionContentModerated": "Contenido de la colección censurado." + }, + "graph": { + "title": "Grafo", + "legend": { + "title": "Leyenda", + "self": "Tú", + "friend": "Amigo", + "following": "Siguiendo", + "follower": "Seguidor", + "extended": "Extendido", + "user": "Usuario", + "post": "Publicación", + "tag": "Etiqueta", + "sameCommunity": "Misma comunidad", + "bridge": "Puente", + "followAge": "Antigüedad del follow", + "old": "antiguo", + "new": "nuevo" + }, + "controls": { + "zoomIn": "Acercar", + "zoomOut": "Alejar", + "fit": "Ajustar vista", + "recenter": "Recentrar", + "showPosts": "Mostrar publicaciones", + "showTags": "Mostrar etiquetas", + "declutter": "Despejar", + "communities": "Comunidades", + "timeMachine": "Máquina del tiempo", + "pausePhysics": "Pausar física", + "resumePhysics": "Reanudar física", + "releasePins": "Soltar nodos fijados" + }, + "panel": { + "close": "Cerrar", + "expand": "Expandir", + "expanded": "Expandido", + "focus": "Enfocar", + "openProfile": "Perfil", + "openPost": "Abrir publicación", + "emptyPost": "Sin contenido de texto", + "postBy": "Publicación de {name}", + "searchTag": "Buscar", + "tagUsage": "usada {count, plural, one {# vez} other {# veces}}", + "reply": "Responder", + "tracePath": "¿Cómo estoy conectado?", + "followedBy": "seguido por {count} que sigues" + }, + "states": { + "error": "No se pudo cargar el grafo.", + "retry": "Reintentar", + "empty": "Nada que explorar todavía. Sigue a personas para hacer crecer tu grafo.", + "emptyCta": "Encuentra personas para seguir", + "noUser": "Inicia sesión o busca un usuario o etiqueta para explorar el grafo.", + "tooManyNodes": "Grafo lleno: se ocultaron nodos lejanos.", + "expandError": "No se pudo expandir este nodo.", + "noPath": "No se encontró un camino de seguimiento en 6 saltos.", + "autoDeclutter": "Grafo denso: el despeje está activado. Cámbialo en los controles." + }, + "search": { + "placeholder": "Buscar usuarios o etiquetas" + }, + "time": { + "play": "Reproducir", + "pause": "Pausar", + "scrub": "Recorrer el tiempo", + "now": "Ahora", + "close": "Cerrar la máquina del tiempo" + }, + "stream": { + "mergeMore": "Añadir más", + "empty": "Aún no hay nada en este stream." + } } } diff --git a/messages/fr.json b/messages/fr.json index 4da2de1ab7..4434da9f5b 100644 --- a/messages/fr.json +++ b/messages/fr.json @@ -158,7 +158,8 @@ "feed": "Fil", "search": "Rechercher", "collections": "Collections", - "new": "Nouveau" + "new": "Nouveau", + "graph": "Graphe" }, "settings": { "title": "Paramètres", @@ -582,7 +583,8 @@ "mute": "Masquer {username}", "unmute": "Afficher {username}", "viewProfile": "Voir le profil de {name}", - "thisIsYou": "C'est vous" + "thisIsYou": "C'est vous", + "openInGraph": "Ouvrir dans le graphe" }, "friendsWithYou": "(vous vous suivez mutuellement)", "empty": { @@ -1156,7 +1158,8 @@ "title": "Disposition", "columns": "Colonnes", "wide": "Large", - "visual": "Visuel" + "visual": "Visuel", + "graph": "Graphe" }, "content": { "title": "Contenu", @@ -1453,5 +1456,78 @@ "moderation": { "postContentModerated": "Contenu de la publication censuré.", "collectionContentModerated": "Contenu de la collection censuré." + }, + "graph": { + "title": "Graphe", + "legend": { + "title": "Légende", + "self": "Vous", + "friend": "Ami", + "following": "Abonnement", + "follower": "Abonné", + "extended": "Étendu", + "user": "Utilisateur", + "post": "Publication", + "tag": "Tag", + "sameCommunity": "Même communauté", + "bridge": "Pont", + "followAge": "Ancienneté du follow", + "old": "ancien", + "new": "récent" + }, + "controls": { + "zoomIn": "Zoomer", + "zoomOut": "Dézoomer", + "fit": "Ajuster la vue", + "recenter": "Recentrer", + "showPosts": "Afficher les publications", + "showTags": "Afficher les tags", + "declutter": "Désencombrer", + "communities": "Communautés", + "timeMachine": "Machine à remonter le temps", + "pausePhysics": "Suspendre la physique", + "resumePhysics": "Reprendre la physique", + "releasePins": "Libérer les nœuds épinglés" + }, + "panel": { + "close": "Fermer", + "expand": "Développer", + "expanded": "Développé", + "focus": "Focaliser", + "openProfile": "Profil", + "openPost": "Ouvrir la publication", + "emptyPost": "Aucun contenu textuel", + "postBy": "Publication de {name}", + "searchTag": "Rechercher", + "tagUsage": "utilisé {count, plural, one {# fois} other {# fois}}", + "reply": "Répondre", + "tracePath": "Comment suis-je connecté ?", + "followedBy": "suivi par {count} que vous suivez" + }, + "states": { + "error": "Impossible de charger le graphe.", + "retry": "Réessayer", + "empty": "Rien à explorer pour le moment. Suivez des personnes pour agrandir votre graphe.", + "emptyCta": "Trouver des personnes à suivre", + "noUser": "Connectez-vous ou recherchez un utilisateur ou un tag pour explorer le graphe.", + "tooManyNodes": "Graphe plein : les nœuds éloignés ont été masqués.", + "expandError": "Impossible de développer ce nœud.", + "noPath": "Aucun chemin de suivi trouvé en 6 sauts.", + "autoDeclutter": "Graphe dense : le désencombrement est actif. Modifiable dans les contrôles." + }, + "search": { + "placeholder": "Rechercher des utilisateurs ou des tags" + }, + "time": { + "play": "Lecture", + "pause": "Pause", + "scrub": "Parcourir le temps", + "now": "Maintenant", + "close": "Fermer la machine à remonter le temps" + }, + "stream": { + "mergeMore": "Fusionner plus", + "empty": "Rien dans ce flux pour le moment." + } } } diff --git a/messages/it.json b/messages/it.json index 91cb3be734..c664b5a502 100644 --- a/messages/it.json +++ b/messages/it.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Cerca", "collections": "Collezioni", - "new": "Nuovo" + "new": "Nuovo", + "graph": "Grafo" }, "settings": { "title": "Impostazioni", @@ -582,7 +583,8 @@ "mute": "Silenzia {username}", "unmute": "Riattiva {username}", "viewProfile": "Vedi profilo di {name}", - "thisIsYou": "Sei tu" + "thisIsYou": "Sei tu", + "openInGraph": "Apri nel grafo" }, "friendsWithYou": "(vi seguite a vicenda)", "empty": { @@ -1156,7 +1158,8 @@ "title": "Layout", "columns": "Colonne", "wide": "Largo", - "visual": "Visuale" + "visual": "Visuale", + "graph": "Grafo" }, "content": { "title": "Contenuto", @@ -1453,5 +1456,78 @@ "moderation": { "postContentModerated": "Contenuto del post censurato.", "collectionContentModerated": "Contenuto della collezione censurato." + }, + "graph": { + "title": "Grafo", + "legend": { + "title": "Legenda", + "self": "Tu", + "friend": "Amico", + "following": "Seguiti", + "follower": "Follower", + "extended": "Esteso", + "user": "Utente", + "post": "Post", + "tag": "Tag", + "sameCommunity": "Stessa community", + "bridge": "Ponte", + "followAge": "Età del follow", + "old": "vecchio", + "new": "nuovo" + }, + "controls": { + "zoomIn": "Ingrandisci", + "zoomOut": "Riduci", + "fit": "Adatta vista", + "recenter": "Ricentra", + "showPosts": "Mostra post", + "showTags": "Mostra tag", + "declutter": "Riordina", + "communities": "Comunità", + "timeMachine": "Macchina del tempo", + "pausePhysics": "Pausa fisica", + "resumePhysics": "Riprendi fisica", + "releasePins": "Rilascia i nodi fissati" + }, + "panel": { + "close": "Chiudi", + "expand": "Espandi", + "expanded": "Espanso", + "focus": "Focalizza", + "openProfile": "Profilo", + "openPost": "Apri post", + "emptyPost": "Nessun contenuto testuale", + "postBy": "Post di {name}", + "searchTag": "Cerca", + "tagUsage": "usato {count, plural, one {# volta} other {# volte}}", + "reply": "Rispondi", + "tracePath": "Come sono collegato?", + "followedBy": "seguito da {count} che segui" + }, + "states": { + "error": "Impossibile caricare il grafo.", + "retry": "Riprova", + "empty": "Niente da esplorare per ora. Segui persone per far crescere il tuo grafo.", + "emptyCta": "Trova persone da seguire", + "noUser": "Accedi oppure cerca un utente o un tag per esplorare il grafo.", + "tooManyNodes": "Grafo pieno: i nodi lontani sono stati nascosti.", + "expandError": "Impossibile espandere questo nodo.", + "noPath": "Nessun percorso di follow trovato entro 6 salti.", + "autoDeclutter": "Grafo denso: riordino attivo. Modificabile nei controlli." + }, + "search": { + "placeholder": "Cerca utenti o tag" + }, + "time": { + "play": "Riproduci", + "pause": "Pausa", + "scrub": "Scorri nel tempo", + "now": "Adesso", + "close": "Chiudi la macchina del tempo" + }, + "stream": { + "mergeMore": "Aggiungi altro", + "empty": "Ancora nulla in questo stream." + } } } diff --git a/messages/ja.json b/messages/ja.json index 20229f8f27..eb2219c17c 100644 --- a/messages/ja.json +++ b/messages/ja.json @@ -158,7 +158,8 @@ "feed": "フィード", "search": "検索", "collections": "コレクション", - "new": "新着" + "new": "新着", + "graph": "グラフ" }, "settings": { "title": "設定", @@ -582,7 +583,8 @@ "mute": "{username}をミュート", "unmute": "{username}のミュートを解除", "viewProfile": "{name}のプロフィールを見る", - "thisIsYou": "これはあなたです" + "thisIsYou": "これはあなたです", + "openInGraph": "グラフで開く" }, "friendsWithYou": "(相互フォロー)", "empty": { @@ -1156,7 +1158,8 @@ "title": "レイアウト", "columns": "カラム", "wide": "ワイド", - "visual": "ビジュアル" + "visual": "ビジュアル", + "graph": "グラフ" }, "content": { "title": "コンテンツ", @@ -1453,5 +1456,78 @@ "moderation": { "postContentModerated": "投稿内容は検閲されました。", "collectionContentModerated": "コレクションの内容は検閲されました。" + }, + "graph": { + "title": "グラフ", + "legend": { + "title": "凡例", + "self": "あなた", + "friend": "友達", + "following": "フォロー中", + "follower": "フォロワー", + "extended": "拡張", + "user": "ユーザー", + "post": "投稿", + "tag": "タグ", + "sameCommunity": "同じコミュニティ", + "bridge": "ブリッジ", + "followAge": "フォローの新しさ", + "old": "古い", + "new": "新しい" + }, + "controls": { + "zoomIn": "拡大", + "zoomOut": "縮小", + "fit": "全体表示", + "recenter": "中央に戻す", + "showPosts": "投稿を表示", + "showTags": "タグを表示", + "declutter": "整理", + "communities": "コミュニティ", + "timeMachine": "タイムマシン", + "pausePhysics": "物理を一時停止", + "resumePhysics": "物理を再開", + "releasePins": "固定ノードを解除" + }, + "panel": { + "close": "閉じる", + "expand": "展開", + "expanded": "展開済み", + "focus": "フォーカス", + "openProfile": "プロフィール", + "openPost": "投稿を開く", + "emptyPost": "テキストなし", + "postBy": "{name}の投稿", + "searchTag": "検索", + "tagUsage": "{count}回使用", + "reply": "返信", + "tracePath": "どうつながっている?", + "followedBy": "フォロー中の{count}人がフォロー" + }, + "states": { + "error": "グラフを読み込めませんでした。", + "retry": "再試行", + "empty": "まだ探索するものがありません。フォローしてグラフを育てましょう。", + "emptyCta": "フォローする人を探す", + "noUser": "サインインするか、ユーザーやタグを検索してグラフを探索してください。", + "tooManyNodes": "グラフが満杯です。遠いノードを非表示にしました。", + "expandError": "このノードを展開できませんでした。", + "noPath": "6ホップ以内にフォロー経路が見つかりません。", + "autoDeclutter": "密なグラフのため整理を有効にしました。コントロールで切替できます。" + }, + "search": { + "placeholder": "ユーザーまたはタグを検索" + }, + "time": { + "play": "再生", + "pause": "一時停止", + "scrub": "時間をスクラブ", + "now": "現在", + "close": "タイムマシンを閉じる" + }, + "stream": { + "mergeMore": "さらに統合", + "empty": "このストリームにはまだ何もありません。" + } } } diff --git a/messages/pt-BR.json b/messages/pt-BR.json index dc2f69e544..472fe4bfc7 100644 --- a/messages/pt-BR.json +++ b/messages/pt-BR.json @@ -158,7 +158,8 @@ "feed": "Feed", "search": "Pesquisar", "collections": "Coleções", - "new": "Novo" + "new": "Novo", + "graph": "Grafo" }, "settings": { "title": "Configurações", @@ -582,7 +583,8 @@ "mute": "Silenciar {username}", "unmute": "Reativar {username}", "viewProfile": "Ver perfil de {name}", - "thisIsYou": "Este e você" + "thisIsYou": "Este e você", + "openInGraph": "Abrir no grafo" }, "friendsWithYou": "(vocês se seguem)", "empty": { @@ -1156,7 +1158,8 @@ "title": "Layout", "columns": "Colunas", "wide": "Largo", - "visual": "Visual" + "visual": "Visual", + "graph": "Grafo" }, "content": { "title": "Conteudo", @@ -1453,5 +1456,78 @@ "moderation": { "postContentModerated": "Conteúdo do post censurado.", "collectionContentModerated": "Conteúdo da coleção censurado." + }, + "graph": { + "title": "Grafo", + "legend": { + "title": "Legenda", + "self": "Você", + "friend": "Amigo", + "following": "Seguindo", + "follower": "Seguidor", + "extended": "Estendido", + "user": "Usuário", + "post": "Publicação", + "tag": "Tag", + "sameCommunity": "Mesma comunidade", + "bridge": "Ponte", + "followAge": "Idade do follow", + "old": "antigo", + "new": "novo" + }, + "controls": { + "zoomIn": "Aproximar", + "zoomOut": "Afastar", + "fit": "Ajustar visão", + "recenter": "Recentralizar", + "showPosts": "Mostrar publicações", + "showTags": "Mostrar tags", + "declutter": "Organizar", + "communities": "Comunidades", + "timeMachine": "Máquina do tempo", + "pausePhysics": "Pausar física", + "resumePhysics": "Retomar física", + "releasePins": "Soltar nós fixados" + }, + "panel": { + "close": "Fechar", + "expand": "Expandir", + "expanded": "Expandido", + "focus": "Focar", + "openProfile": "Perfil", + "openPost": "Abrir publicação", + "emptyPost": "Sem conteúdo de texto", + "postBy": "Publicação de {name}", + "searchTag": "Buscar", + "tagUsage": "usada {count, plural, one {# vez} other {# vezes}}", + "reply": "Responder", + "tracePath": "Como estou conectado?", + "followedBy": "seguido por {count} que você segue" + }, + "states": { + "error": "Não foi possível carregar o grafo.", + "retry": "Tentar novamente", + "empty": "Nada para explorar ainda. Siga pessoas para expandir seu grafo.", + "emptyCta": "Encontrar pessoas para seguir", + "noUser": "Entre ou pesquise um usuário ou tag para explorar o grafo.", + "tooManyNodes": "Grafo cheio: nós distantes foram ocultados.", + "expandError": "Não foi possível expandir este nó.", + "noPath": "Nenhum caminho de follow encontrado em 6 saltos.", + "autoDeclutter": "Grafo denso: organização ativada. Alterne nos controles." + }, + "search": { + "placeholder": "Buscar usuários ou tags" + }, + "time": { + "play": "Reproduzir", + "pause": "Pausar", + "scrub": "Percorrer o tempo", + "now": "Agora", + "close": "Fechar a máquina do tempo" + }, + "stream": { + "mergeMore": "Mesclar mais", + "empty": "Nada neste stream ainda." + } } } diff --git a/messages/zh.json b/messages/zh.json index e40a15be2e..0a1487669a 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -158,7 +158,8 @@ "feed": "动态", "search": "搜索", "collections": "收藏集", - "new": "新" + "new": "新", + "graph": "关系图" }, "settings": { "title": "设置", @@ -582,7 +583,8 @@ "mute": "静音 {username}", "unmute": "取消静音 {username}", "viewProfile": "查看 {name} 的个人资料", - "thisIsYou": "这是您" + "thisIsYou": "这是您", + "openInGraph": "在关系图中打开" }, "friendsWithYou": "(互相关注)", "empty": { @@ -1150,7 +1152,8 @@ "title": "布局", "columns": "多列", "wide": "宽屏", - "visual": "视觉" + "visual": "视觉", + "graph": "关系图" }, "content": { "title": "内容", @@ -1447,5 +1450,78 @@ "moderation": { "postContentModerated": "帖子内容已被审核。", "collectionContentModerated": "收藏集内容已被审核。" + }, + "graph": { + "title": "关系图", + "legend": { + "title": "图例", + "self": "你", + "friend": "好友", + "following": "关注中", + "follower": "粉丝", + "extended": "扩展", + "user": "用户", + "post": "帖子", + "tag": "标签", + "sameCommunity": "同一社群", + "bridge": "桥接", + "followAge": "关注时间", + "old": "旧", + "new": "新" + }, + "controls": { + "zoomIn": "放大", + "zoomOut": "缩小", + "fit": "适应视图", + "recenter": "回到中心", + "showPosts": "显示帖子", + "showTags": "显示标签", + "declutter": "整理", + "communities": "社区", + "timeMachine": "时光机", + "pausePhysics": "暂停物理", + "resumePhysics": "恢复物理", + "releasePins": "释放固定节点" + }, + "panel": { + "close": "关闭", + "expand": "展开", + "expanded": "已展开", + "focus": "聚焦", + "openProfile": "个人主页", + "openPost": "打开帖子", + "emptyPost": "无文本内容", + "postBy": "{name}的帖子", + "searchTag": "搜索", + "tagUsage": "使用了{count}次", + "reply": "回复", + "tracePath": "我如何与其相连?", + "followedBy": "你关注的{count}人也关注了" + }, + "states": { + "error": "无法加载关系图。", + "retry": "重试", + "empty": "暂无可探索的内容。关注他人以扩展你的关系图。", + "emptyCta": "寻找可关注的人", + "noUser": "登录或搜索用户或标签以探索关系图。", + "tooManyNodes": "关系图已满:远处节点已隐藏。", + "expandError": "无法展开该节点。", + "noPath": "6 跳以内未找到关注路径。", + "autoDeclutter": "关系图较密集,已开启整理。可在控制栏切换。" + }, + "search": { + "placeholder": "搜索用户或标签" + }, + "time": { + "play": "播放", + "pause": "暂停", + "scrub": "拖动时间轴", + "now": "现在", + "close": "关闭时光机" + }, + "stream": { + "mergeMore": "合并更多", + "empty": "该流中还没有内容。" + } } } diff --git a/package-lock.json b/package-lock.json index b574336820..29f432ca3e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27,6 +27,8 @@ "embla-carousel-react": "8.6.0", "emoji-mart": "5.6.0", "facehash": "0.1.0", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "jszip": "3.10.1", "libphonenumber-js": "1.13.2", "linkify-it": "5.0.0", @@ -43,6 +45,7 @@ "react-dom": "19.2.6", "react-easy-crop": "5.5.7", "react-error-boundary": "6.1.1", + "react-force-graph-2d": "^1.29.1", "react-hook-form": "7.76.0", "react-markdown": "10.1.0", "react-syntax-highlighter": "16.1.1", @@ -7205,6 +7208,12 @@ "dev": true, "license": "MIT" }, + "node_modules/@tweenjs/tween.js": { + "version": "25.0.0", + "resolved": "https://registry.npmjs.org/@tweenjs/tween.js/-/tween.js-25.0.0.tgz", + "integrity": "sha512-XKLA6syeBUaPzx4j3qwMqzzq+V4uo72BnlbOjmuljLrRqdsd3qnzvZZoxvMHZ23ndsRS4aufU6JOZYpCbU6T1A==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.2", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", @@ -8452,6 +8461,15 @@ "license": "Apache-2.0", "peer": true }, + "node_modules/accessor-fn": { + "version": "1.5.3", + "resolved": "https://registry.npmjs.org/accessor-fn/-/accessor-fn-1.5.3.tgz", + "integrity": "sha512-rkAofCwe/FvYFUlMB0v0gWmhqtfAtV1IUkdPbfhTUyYniu5LrC0A0UJkTH0Jv3S8SvwkmfuAlY+mQIJATdocMA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/acorn": { "version": "8.16.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", @@ -9100,6 +9118,16 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/bezier-js": { + "version": "6.1.4", + "resolved": "https://registry.npmjs.org/bezier-js/-/bezier-js-6.1.4.tgz", + "integrity": "sha512-PA0FW9ZpcHbojUCMu28z9Vg/fNkwTj5YhusSAjHHDfHDGLxJ6YUKrAN2vk1fP2MMOxVw4Oko16FMlRGVBGqLKg==", + "license": "MIT", + "funding": { + "type": "individual", + "url": "https://github.com/Pomax/bezierjs/blob/master/FUNDING.md" + } + }, "node_modules/bidi-js": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", @@ -9332,6 +9360,18 @@ ], "license": "CC-BY-4.0" }, + "node_modules/canvas-color-tracker": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/canvas-color-tracker/-/canvas-color-tracker-1.3.2.tgz", + "integrity": "sha512-ryQkDX26yJ3CXzb3hxUVNlg1NKE4REc5crLBq661Nxzr8TNd236SaEf2ffYLXyI5tSABSeguHLqcVq4vf9L3Zg==", + "license": "MIT", + "dependencies": { + "tinycolor2": "^1.6.0" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/caseless": { "version": "0.12.0", "resolved": "https://registry.npmjs.org/caseless/-/caseless-0.12.0.tgz", @@ -9877,6 +9917,222 @@ "dev": true, "license": "MIT" }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-binarytree": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d3-binarytree/-/d3-binarytree-1.0.2.tgz", + "integrity": "sha512-cElUNH+sHu95L04m92pG73t2MEJXKu+GeKUN1TJkFsu93E5W8E9Sc3kHEGJKgenGvj19m6upSn2EunvMgMD2Yw==", + "license": "MIT" + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-dispatch": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-dispatch/-/d3-dispatch-3.0.1.tgz", + "integrity": "sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-drag": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-drag/-/d3-drag-3.0.0.tgz", + "integrity": "sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-selection": "3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-force-3d": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/d3-force-3d/-/d3-force-3d-3.0.6.tgz", + "integrity": "sha512-4tsKHUPLOVkyfEffZo1v6sFHvGFwAIIjt/W8IThbp08DYAsXZck+2pSHEG5W1+gQgEvFLdZkYvmJAbRM2EzMnA==", + "license": "MIT", + "dependencies": { + "d3-binarytree": "1", + "d3-dispatch": "1 - 3", + "d3-octree": "1", + "d3-quadtree": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-octree": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/d3-octree/-/d3-octree-1.1.0.tgz", + "integrity": "sha512-F8gPlqpP+HwRPMO/8uOu5wjH110+6q4cgJvgJT6vlpy3BEaDIKlTZrgHKZSp/i1InRpVfh4puY/kvL6MxK930A==", + "license": "MIT" + }, + "node_modules/d3-quadtree": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-quadtree/-/d3-quadtree-3.0.1.tgz", + "integrity": "sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale-chromatic": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-scale-chromatic/-/d3-scale-chromatic-3.1.0.tgz", + "integrity": "sha512-A3s5PWiZ9YCXFye1o246KoscMWqf8BsD9eRiJ3He7C9OBaxKhAd5TFCdEx/7VbKtxxTsu//1mMJFrEt572cEyQ==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-interpolate": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-selection": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-selection/-/d3-selection-3.0.0.tgz", + "integrity": "sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-transition": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-transition/-/d3-transition-3.0.1.tgz", + "integrity": "sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3", + "d3-dispatch": "1 - 3", + "d3-ease": "1 - 3", + "d3-interpolate": "1 - 3", + "d3-timer": "1 - 3" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "d3-selection": "2 - 3" + } + }, + "node_modules/d3-zoom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/d3-zoom/-/d3-zoom-3.0.0.tgz", + "integrity": "sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==", + "license": "ISC", + "dependencies": { + "d3-dispatch": "1 - 3", + "d3-drag": "2 - 3", + "d3-interpolate": "1 - 3", + "d3-selection": "2 - 3", + "d3-transition": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/damerau-levenshtein": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", @@ -11011,7 +11267,6 @@ "resolved": "https://registry.npmjs.org/events/-/events-3.3.0.tgz", "integrity": "sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.8.x" } @@ -11376,6 +11631,20 @@ "dev": true, "license": "ISC" }, + "node_modules/float-tooltip": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/float-tooltip/-/float-tooltip-1.7.5.tgz", + "integrity": "sha512-/kXzuDnnBqyyWyhDMH7+PfP8J/oXiAavGzcRxASOMRHFuReDtofizLLJsf7nnDLAfEaMW4pVWaXrAjtnglpEkg==", + "license": "MIT", + "dependencies": { + "d3-selection": "2 - 3", + "kapsule": "^1.16", + "preact": "10" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/for-each": { "version": "0.3.5", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", @@ -11392,6 +11661,32 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/force-graph": { + "version": "1.51.4", + "resolved": "https://registry.npmjs.org/force-graph/-/force-graph-1.51.4.tgz", + "integrity": "sha512-TdJ2KbkoiDQ7NIRx8IPGD0mAXXpLhamS7c+b7W98b0MHG7lphnda1VOQX/98UDTsttIAdH4TcP0l0MauSnLK8w==", + "license": "MIT", + "dependencies": { + "@tweenjs/tween.js": "18 - 25", + "accessor-fn": "1", + "bezier-js": "3 - 6", + "canvas-color-tracker": "^1.3", + "d3-array": "1 - 3", + "d3-drag": "2 - 3", + "d3-force-3d": "2 - 3", + "d3-scale": "1 - 4", + "d3-scale-chromatic": "1 - 3", + "d3-selection": "2 - 3", + "d3-zoom": "2 - 3", + "float-tooltip": "^1.7", + "index-array-by": "1", + "kapsule": "^1.16", + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/forever-agent": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/forever-agent/-/forever-agent-0.6.1.tgz", @@ -11813,6 +12108,62 @@ "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", "license": "ISC" }, + "node_modules/graphology": { + "version": "0.26.0", + "resolved": "https://registry.npmjs.org/graphology/-/graphology-0.26.0.tgz", + "integrity": "sha512-8SSImzgUUYC89Z042s+0r/vMibY7GX/Emz4LDO5e7jYXhuoWfHISPFJYjpRLUSJGq6UQ6xlenvX1p/hJdfXuXg==", + "license": "MIT", + "dependencies": { + "events": "^3.3.0" + }, + "peerDependencies": { + "graphology-types": ">=0.24.0" + } + }, + "node_modules/graphology-communities-louvain": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/graphology-communities-louvain/-/graphology-communities-louvain-2.0.2.tgz", + "integrity": "sha512-zt+2hHVPYxjEquyecxWXoUoIuN/UvYzsvI7boDdMNz0rRvpESQ7+e+Ejv6wK7AThycbZXuQ6DkG8NPMCq6XwoA==", + "license": "MIT", + "dependencies": { + "graphology-indices": "^0.17.0", + "graphology-utils": "^2.4.4", + "mnemonist": "^0.39.0", + "pandemonium": "^2.4.1" + }, + "peerDependencies": { + "graphology-types": ">=0.19.0" + } + }, + "node_modules/graphology-indices": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/graphology-indices/-/graphology-indices-0.17.0.tgz", + "integrity": "sha512-A7RXuKQvdqSWOpn7ZVQo4S33O0vCfPBnUSf7FwE0zNCasqwZVUaCXePuWo5HBpWw68KJcwObZDHpFk6HKH6MYQ==", + "license": "MIT", + "dependencies": { + "graphology-utils": "^2.4.2", + "mnemonist": "^0.39.0" + }, + "peerDependencies": { + "graphology-types": ">=0.20.0" + } + }, + "node_modules/graphology-types": { + "version": "0.24.8", + "resolved": "https://registry.npmjs.org/graphology-types/-/graphology-types-0.24.8.tgz", + "integrity": "sha512-hDRKYXa8TsoZHjgEaysSRyPdT6uB78Ci8WnjgbStlQysz7xR52PInxNsmnB7IBOM1BhikxkNyCVEFgmPKnpx3Q==", + "license": "MIT", + "peer": true + }, + "node_modules/graphology-utils": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/graphology-utils/-/graphology-utils-2.5.2.tgz", + "integrity": "sha512-ckHg8MXrXJkOARk56ZaSCM1g1Wihe2d6iTmz1enGOz4W/l831MBCKSayeFQfowgF8wd+PQ4rlch/56Vs/VZLDQ==", + "license": "MIT", + "peerDependencies": { + "graphology-types": ">=0.23.0" + } + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", @@ -12253,6 +12604,15 @@ "node": ">=8" } }, + "node_modules/index-array-by": { + "version": "1.4.2", + "resolved": "https://registry.npmjs.org/index-array-by/-/index-array-by-1.4.2.tgz", + "integrity": "sha512-SP23P27OUKzXWEC/TOyWlwLviofQkCSCKONnc62eItjp69yCZZPqDQtr3Pw5gJDnPeUMqExmKydNZaJO0FU9pw==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/inherits": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", @@ -12290,6 +12650,15 @@ "node": ">= 0.4" } }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, "node_modules/intl-messageformat": { "version": "11.2.6", "resolved": "https://registry.npmjs.org/intl-messageformat/-/intl-messageformat-11.2.6.tgz", @@ -12955,6 +13324,15 @@ "node": ">= 0.4" } }, + "node_modules/jerrypick": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/jerrypick/-/jerrypick-1.1.2.tgz", + "integrity": "sha512-YKnxXEekXKzhpf7CLYA0A+oDP8V0OhICNCr5lv96FvSsDEmrb0GKM776JgQvHTMjr7DTTPEVv/1Ciaw0uEWzBA==", + "license": "MIT", + "engines": { + "node": ">=12" + } + }, "node_modules/jest-worker": { "version": "27.5.1", "resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-27.5.1.tgz", @@ -13219,6 +13597,18 @@ "setimmediate": "^1.0.5" } }, + "node_modules/kapsule": { + "version": "1.16.3", + "resolved": "https://registry.npmjs.org/kapsule/-/kapsule-1.16.3.tgz", + "integrity": "sha512-4+5mNNf4vZDSwPhKprKwz3330iisPrb08JyMgbsdFrimBCKNHecua/WBwvVg3n7vwx0C1ARjfhwIpbrbd9n5wg==", + "license": "MIT", + "dependencies": { + "lodash-es": "4" + }, + "engines": { + "node": ">=12" + } + }, "node_modules/keyv": { "version": "4.5.4", "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", @@ -15485,6 +15875,15 @@ "node": ">=16 || 14 >=14.17" } }, + "node_modules/mnemonist": { + "version": "0.39.8", + "resolved": "https://registry.npmjs.org/mnemonist/-/mnemonist-0.39.8.tgz", + "integrity": "sha512-vyWo2K3fjrUw8YeeZ1zF0fy6Mu59RHokURlld8ymdUPjMlD9EC9ov1/YPqTgqRvUN9nTr3Gqfz29LYAmu0PHPQ==", + "license": "MIT", + "dependencies": { + "obliterator": "^2.0.1" + } + }, "node_modules/module-details-from-path": { "version": "1.0.4", "resolved": "https://registry.npmjs.org/module-details-from-path/-/module-details-from-path-1.0.4.tgz", @@ -15955,6 +16354,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/obliterator": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/obliterator/-/obliterator-2.0.5.tgz", + "integrity": "sha512-42CPE9AhahZRsMNslczq0ctAEtqk8Eka26QofnqC346BZdHDySk3LWka23LI7ULIw11NmltpiLagIq8gBozxTw==", + "license": "MIT" + }, "node_modules/obug": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.1.tgz", @@ -16087,6 +16492,15 @@ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==", "license": "(MIT AND Zlib)" }, + "node_modules/pandemonium": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/pandemonium/-/pandemonium-2.4.1.tgz", + "integrity": "sha512-wRqjisUyiUfXowgm7MFH2rwJzKIr20rca5FsHXCMNm1W5YPP1hCtrZfgmQ62kP7OZ7Xt+cR858aB28lu5NX55g==", + "license": "MIT", + "dependencies": { + "mnemonist": "^0.39.2" + } + }, "node_modules/parent-module": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", @@ -16367,6 +16781,16 @@ "node": ">=0.10.0" } }, + "node_modules/preact": { + "version": "10.29.4", + "resolved": "https://registry.npmjs.org/preact/-/preact-10.29.4.tgz", + "integrity": "sha512-GMpwh9+NJ8tSmqwIaVyFRQkiKfBEzQ+k7r7tle4W+kaJ+7wJiB9hFz9BixAomMtenPPSBfM4bZhXozGxhf0uFQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/preact" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", @@ -16773,6 +17197,23 @@ "react": "^18.0.0 || ^19.0.0" } }, + "node_modules/react-force-graph-2d": { + "version": "1.29.1", + "resolved": "https://registry.npmjs.org/react-force-graph-2d/-/react-force-graph-2d-1.29.1.tgz", + "integrity": "sha512-1Rl/1Z3xy2iTHKj6a0jRXGyiI86xUti81K+jBQZ+Oe46csaMikp47L5AjrzA9hY9fNGD63X8ffrqnvaORukCuQ==", + "license": "MIT", + "dependencies": { + "force-graph": "^1.51", + "prop-types": "15", + "react-kapsule": "^2.5" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": "*" + } + }, "node_modules/react-hook-form": { "version": "7.76.0", "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.76.0.tgz", @@ -16795,6 +17236,21 @@ "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", "license": "MIT" }, + "node_modules/react-kapsule": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/react-kapsule/-/react-kapsule-2.6.0.tgz", + "integrity": "sha512-HzLJoYb1n1kfwjXbqFFcRR0EA6oPsJ64tNdDmCSaL/bz2o9wUZRSb0cMe//grLFeF9EVoL4CD/e6ozLyzEv+PQ==", + "license": "MIT", + "dependencies": { + "jerrypick": "^1.1.2" + }, + "engines": { + "node": ">=12" + }, + "peerDependencies": { + "react": ">=16.13.1" + } + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", @@ -18427,6 +18883,12 @@ "dev": true, "license": "MIT" }, + "node_modules/tinycolor2": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/tinycolor2/-/tinycolor2-1.6.0.tgz", + "integrity": "sha512-XPaBkWQJdsf3pLKJV9p4qN/S+fm2Oj8AIPo1BTUhg5oxkvm9+SVEGFdhyOz7tTdUTfvxMiAs4sp6/eZO2Ew+pw==", + "license": "MIT" + }, "node_modules/tinyexec": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.1.2.tgz", diff --git a/package.json b/package.json index 028b2c8c40..37aa930689 100644 --- a/package.json +++ b/package.json @@ -53,6 +53,8 @@ "embla-carousel-react": "8.6.0", "emoji-mart": "5.6.0", "facehash": "0.1.0", + "graphology": "^0.26.0", + "graphology-communities-louvain": "^2.0.2", "jszip": "3.10.1", "libphonenumber-js": "1.13.2", "linkify-it": "5.0.0", @@ -69,6 +71,7 @@ "react-dom": "19.2.6", "react-easy-crop": "5.5.7", "react-error-boundary": "6.1.1", + "react-force-graph-2d": "^1.29.1", "react-hook-form": "7.76.0", "react-markdown": "10.1.0", "react-syntax-highlighter": "16.1.1", diff --git a/src/app/graph/page.tsx b/src/app/graph/page.tsx new file mode 100644 index 0000000000..231d0b7170 --- /dev/null +++ b/src/app/graph/page.tsx @@ -0,0 +1,26 @@ +import { Suspense } from 'react'; +import { Container } from '@/atoms/Container/Container'; +import { Spinner } from '@/atoms/Spinner/Spinner'; +import { Metadata } from '@/molecules/Metadata/Metadata'; +import { Graph } from '@/templates/Graph/Graph'; + +export const metadata = Metadata({ + title: 'Graph', + description: 'Explore the Pubky social graph.', +}); + +function GraphLoadingFallback() { + return ( + + + + ); +} + +export default function GraphPage() { + return ( + }> + + + ); +} diff --git a/src/app/routes.ts b/src/app/routes.ts index c7a04bbe5e..4133159db5 100644 --- a/src/app/routes.ts +++ b/src/app/routes.ts @@ -26,6 +26,7 @@ export enum APP_ROUTES { PROFILE = '/profile', WHO_TO_FOLLOW = '/who-to-follow', SHARE = '/share', + GRAPH = '/graph', } export enum COLLECTION_ROUTES { @@ -68,7 +69,7 @@ export enum DEV_ROUTES { SENTRY_TEST = '/sentry-test', } -export const EXPLORE_ROUTES: string[] = [APP_ROUTES.HOME, APP_ROUTES.HOT, APP_ROUTES.SEARCH]; +export const EXPLORE_ROUTES: string[] = [APP_ROUTES.HOME, APP_ROUTES.HOT, APP_ROUTES.SEARCH, APP_ROUTES.GRAPH]; // Public routes are accessible regardless of authentication status. // This includes routes that need to be accessible during auth transitions (like logout). @@ -101,6 +102,7 @@ export const ALLOWED_ROUTES = [ APP_ROUTES.PROFILE, APP_ROUTES.WHO_TO_FOLLOW, APP_ROUTES.SHARE, + APP_ROUTES.GRAPH, POST_ROUTES.POST, AUTH_ROUTES.LOGOUT, ]; diff --git a/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx new file mode 100644 index 0000000000..16a64392b0 --- /dev/null +++ b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.test.tsx @@ -0,0 +1,18 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it } from 'vitest'; +import { CanvasAnchoredPopover } from './CanvasAnchoredPopover'; + +describe('CanvasAnchoredPopover', () => { + it('renders its content absolutely positioned with the given data-cy', () => { + render( +
+ + content + +
, + ); + expect(screen.getByText('content')).toBeInTheDocument(); + const el = document.querySelector('[data-cy="anchored"]') as HTMLElement; + expect(el.className).toContain('absolute'); + }); +}); diff --git a/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx new file mode 100644 index 0000000000..47519550fa --- /dev/null +++ b/src/components/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover.tsx @@ -0,0 +1,71 @@ +'use client'; + +import { useLayoutEffect, useRef, useState } from 'react'; +import { cn } from '@/libs/utils/utils'; + +export interface CanvasAnchoredPopoverProps { + /** Anchor point, relative to the positioned ancestor (the graph page container) */ + x: number; + y: number; + /** Gap between the anchor point and the popover edge */ + offset?: number; + onPointerEnter?: () => void; + onPointerLeave?: () => void; + className?: string; + children: React.ReactNode; + 'data-cy'?: string; +} + +/** + * CanvasAnchoredPopover + * + * Positioner for overlays anchored to canvas entities (nodes, edge chips). + * DOM-anchored popovers cannot track a force-graph camera, so this positions + * absolutely inside the page container, prefers the anchor's right side, + * flips left when it would overflow, and clamps so the content always spawns + * fully visible. Callers re-render it with fresh coordinates per frame (see + * useTrackedPoint in the Graph template), which makes it follow pan, zoom, + * and node drags. + */ +export function CanvasAnchoredPopover({ + x, + y, + offset = 14, + onPointerEnter, + onPointerLeave, + className, + children, + 'data-cy': dataCy, +}: CanvasAnchoredPopoverProps) { + const ref = useRef(null); + const [position, setPosition] = useState<{ left: number; top: number } | null>(null); + + // Runs on every render on purpose: coordinates change per frame and the + // content can resize as it loads; the bail-out below keeps it loop-free + // eslint-disable-next-line react-hooks/exhaustive-deps + useLayoutEffect(() => { + const el = ref.current; + const bounds = el?.offsetParent?.getBoundingClientRect(); + if (!el || !bounds) return; + const { width, height } = el.getBoundingClientRect(); + let left = x + offset; + if (left + width > bounds.width - 8) left = x - width - offset; + left = Math.max(8, Math.min(left, bounds.width - width - 8)); + const top = Math.max(8, Math.min(y - height / 2, bounds.height - height - 8)); + setPosition((prev) => (prev && prev.left === left && prev.top === top ? prev : { left, top })); + }); + + return ( +
+ {children} +
+ ); +} diff --git a/src/components/molecules/Fab/Fab.tsx b/src/components/molecules/Fab/Fab.tsx index 950a96291e..f2ac96c0e6 100644 --- a/src/components/molecules/Fab/Fab.tsx +++ b/src/components/molecules/Fab/Fab.tsx @@ -1,7 +1,9 @@ 'use client'; import { useState } from 'react'; +import { usePathname } from 'next/navigation'; import { Plus } from 'lucide-react'; +import { APP_ROUTES } from '@/app/routes'; import { Button } from '@/atoms/Button/Button'; import { useAuthStatus } from '@/hooks/useAuthStatus/useAuthStatus'; import { useFabAction } from '@/hooks/useFabAction/useFabAction'; @@ -37,9 +39,13 @@ export function Fab() { const { isPublicExploreRoute } = usePublicRoute(); const { requireAuth } = useRequireAuth(); const action = useFabAction(); + const pathname = usePathname(); - // Show FAB for authenticated users OR unauthenticated users on public explore routes - const shouldShow = isFullyAuthenticated || isPublicExploreRoute; + // Show FAB for authenticated users OR unauthenticated users on public explore routes. + // The graph explorer is a full-bleed canvas, not a composer surface: the FAB + // would float over its inspector sheet and controls. + const isGraphRoute = pathname?.startsWith(APP_ROUTES.GRAPH) ?? false; + const shouldShow = (isFullyAuthenticated || isPublicExploreRoute) && !isGraphRoute; if (isLoading || !shouldShow) { return null; } diff --git a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx index d4f669f590..afad27dd65 100644 --- a/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx +++ b/src/components/molecules/Filters/FilterLayout/FilterLayout.tsx @@ -1,7 +1,7 @@ 'use client'; import * as React from 'react'; -import { Columns3, LayoutGrid, Menu } from 'lucide-react'; +import { Columns3, LayoutGrid, Menu, Waypoints } from 'lucide-react'; import { useTranslations } from 'next-intl'; import { LAYOUT, type LayoutType } from '@/stores/home/home.types'; import { FilterRadioGroup } from '../FilterRadioGroup/FilterRadioGroup'; @@ -18,7 +18,8 @@ export function FilterLayout({ showVisual = false, }: FilterLayoutProps) { const t = useTranslations('filters.layout'); - const displaySelectedTab = !showVisual && selectedTab === LAYOUT.VISUAL ? LAYOUT.COLUMNS : selectedTab; + const displaySelectedTab = + !showVisual && (selectedTab === LAYOUT.VISUAL || selectedTab === LAYOUT.GRAPH) ? LAYOUT.COLUMNS : selectedTab; const items = React.useMemo( () => [ @@ -45,6 +46,15 @@ export function FilterLayout({ dataCy: 'visual-layout-toggle', } : null, + showVisual + ? { + key: LAYOUT.GRAPH, + label: t('graph'), + icon: Waypoints, + disabled, + dataCy: 'graph-layout-toggle', + } + : null, ].filter(Boolean) as FilterListItem[], [t, disabled, showVisual], ); diff --git a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx b/src/components/molecules/FollowButton/FollowButton.test.tsx similarity index 55% rename from src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx rename to src/components/molecules/FollowButton/FollowButton.test.tsx index 409457a521..92d5e94373 100644 --- a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx +++ b/src/components/molecules/FollowButton/FollowButton.test.tsx @@ -1,15 +1,15 @@ import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; -import { UserInfoPopoverFollowButton } from './UserInfoPopoverFollowButton'; +import { FollowButton } from './FollowButton'; -describe('UserInfoPopoverFollowButton', () => { +describe('FollowButton', () => { beforeEach(() => { vi.clearAllMocks(); }); it('renders Follow state when not following', () => { const onClick = vi.fn(); - render(); + render(); const button = screen.getByLabelText('Follow'); expect(button).toBeInTheDocument(); @@ -18,37 +18,36 @@ describe('UserInfoPopoverFollowButton', () => { }); it('renders Unfollow state when following', () => { - render(); + render(); expect(screen.getByLabelText('Unfollow')).toBeInTheDocument(); }); it('renders loading state and disables button', () => { - render(); + render(); const button = screen.getByLabelText('Follow') as HTMLButtonElement; expect(button).toBeDisabled(); expect(button.querySelector('.lucide-loader-circle')).toBeInTheDocument(); }); + + it('appends a caller className', () => { + render(); + expect(screen.getByLabelText('Follow')).toHaveClass('flex-1'); + }); }); -describe('UserInfoPopoverFollowButton - Snapshots', () => { +describe('FollowButton - Snapshots', () => { it('matches snapshot for follow state', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); it('matches snapshot for following state', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); it('matches snapshot for loading state', () => { - const { container } = render( - , - ); + const { container } = render(); expect(container.firstChild).toMatchSnapshot(); }); }); diff --git a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap b/src/components/molecules/FollowButton/FollowButton.test.tsx.snap similarity index 93% rename from src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap rename to src/components/molecules/FollowButton/FollowButton.test.tsx.snap index 619e52449f..8f6c5eb845 100644 --- a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.test.tsx.snap +++ b/src/components/molecules/FollowButton/FollowButton.test.tsx.snap @@ -1,6 +1,6 @@ // Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html -exports[`UserInfoPopoverFollowButton - Snapshots > matches snapshot for follow state 1`] = ` +exports[`FollowButton - Snapshots > matches snapshot for follow state 1`] = ` `; -exports[`UserInfoPopoverFollowButton - Snapshots > matches snapshot for following state 1`] = ` +exports[`FollowButton - Snapshots > matches snapshot for following state 1`] = ` `; -exports[`UserInfoPopoverFollowButton - Snapshots > matches snapshot for loading state 1`] = ` +exports[`FollowButton - Snapshots > matches snapshot for loading state 1`] = ` + ); +} diff --git a/src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx b/src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx new file mode 100644 index 0000000000..52cc85d8e7 --- /dev/null +++ b/src/components/molecules/GraphBreadcrumbs/GraphBreadcrumbs.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { ChevronRight } from 'lucide-react'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { FileController } from '@/controllers/file/file'; +import type { TrailEntry } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { cn } from '@/libs/utils/utils'; +import { AvatarWithFallback } from '@/organisms/AvatarWithFallback/AvatarWithFallback'; + +export interface GraphBreadcrumbsProps { + trail: TrailEntry[]; + onHop: (entry: TrailEntry) => void; + className?: string; +} + +/** + * GraphBreadcrumbs + * + * The focus history as avatar chips (me, John, Lyn, ...). Clicking a chip + * refocuses there, so deep explorations always have a way back. + */ +export function GraphBreadcrumbs({ trail, onHop, className }: GraphBreadcrumbsProps) { + if (trail.length < 2) return null; + + return ( +
+ {trail.map((entry, i) => ( +
+ {i > 0 && } + +
+ ))} +
+ ); +} diff --git a/src/components/molecules/GraphSearch/GraphSearch.tsx b/src/components/molecules/GraphSearch/GraphSearch.tsx new file mode 100644 index 0000000000..70004a6643 --- /dev/null +++ b/src/components/molecules/GraphSearch/GraphSearch.tsx @@ -0,0 +1,125 @@ +'use client'; + +import { Loader2, Search, Tag as TagIcon } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Input } from '@/atoms/Input/Input'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { useSearchAutocomplete } from '@/hooks/useSearchAutocomplete/useSearchAutocomplete'; +import { useSearchInput } from '@/hooks/useSearchInput/useSearchInput'; +import { cn, generateRandomColor, hexToRgba } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import { SearchUserSuggestion } from '@/molecules/SearchUserSuggestion/SearchUserSuggestion'; + +export interface GraphSearchProps { + onPickUser: (pubky: Pubky) => void; + onPickTag: (label: string) => void; + className?: string; +} + +/** + * GraphSearch + * + * Go-anywhere box for the canvas: reuses the app's search autocomplete and + * hands the chosen user/tag to the graph, which jumps there or merges the + * new neighborhood in. + */ +export function GraphSearch({ onPickUser, onPickTag, className }: GraphSearchProps) { + const t = useTranslations('graph'); + // House search-box state: value, focus, Escape, and outside-click close. + // Enter is a no-op here (picking happens by clicking a result); returning + // false keeps the typed query. + const { + inputValue, + isFocused, + containerRef, + inputRef, + handleInputChange, + handleKeyDown, + handleFocus, + clearInputValue, + setFocus, + } = useSearchInput({ onEnter: () => false }); + const { tags, users, isLoading } = useSearchAutocomplete({ + query: inputValue, + enabled: isFocused && inputValue.length > 0, + }); + + const pickUser = (pubky: Pubky) => { + onPickUser(pubky); + clearInputValue(); + setFocus(false); + }; + const pickTag = (label: string) => { + onPickTag(label); + clearInputValue(); + setFocus(false); + }; + + const hasResults = users.length > 0 || tags.length > 0; + const open = isFocused && inputValue.length > 0 && hasResults; + + return ( +
+
+ {isLoading ? ( + + ) : ( + + )} + +
+ + {open && ( +
+ {users.map((user) => ( +
+ +
+ ))} + {tags.map((tag) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx b/src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx new file mode 100644 index 0000000000..387d944393 --- /dev/null +++ b/src/components/molecules/GraphTimeMachine/GraphTimeMachine.tsx @@ -0,0 +1,140 @@ +'use client'; + +import { useEffect, useRef, useState } from 'react'; +import { Pause, Play, X } from 'lucide-react'; +import { useFormatter, useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { cn } from '@/libs/utils/utils'; + +export interface GraphTimeMachineProps { + bounds: { min: number; max: number }; + /** Sorted ascending event timestamps; playback reveals at a constant EVENT + * rate so heavily skewed timelines still read as steady assembly */ + timestamps?: number[]; + cap: number | null; + onCapChange: (cap: number | null) => void; + onClose: () => void; + className?: string; +} + +const PLAY_DURATION_MS = 8000; +const PLAY_TICK_MS = 50; + +/** + * GraphTimeMachine + * + * A timeline scrubber over the graph's edge/post timestamps: drag to watch + * the network at any moment, press play to watch it assemble itself. + */ +export function GraphTimeMachine({ bounds, timestamps, cap, onCapChange, onClose, className }: GraphTimeMachineProps) { + const t = useTranslations('graph'); + const format = useFormatter(); + const [playing, setPlaying] = useState(false); + const playRef = useRef | null>(null); + + const value = cap ?? bounds.max; + const span = Math.max(1, bounds.max - bounds.min); + + useEffect(() => { + if (!playing) { + if (playRef.current) clearInterval(playRef.current); + return; + } + const stamps = timestamps && timestamps.length > 2 ? timestamps : null; + if (stamps) { + // Constant event-rate playback: real timelines are heavily skewed + // toward recent activity, so linear time playback shows nothing for + // seconds and then everything at once + const perTick = stamps.length / (PLAY_DURATION_MS / PLAY_TICK_MS); + // Restart from the beginning when already at the end + let index = + value >= bounds.max + ? 0 + : Math.max( + 0, + stamps.findIndex((s) => s >= value), + ); + onCapChange(stamps[Math.floor(index)]); + playRef.current = setInterval(() => { + index += perTick; + if (index >= stamps.length - 1) { + onCapChange(null); + setPlaying(false); + } else { + onCapChange(stamps[Math.floor(index)]); + } + }, PLAY_TICK_MS); + } else { + const step = (span / PLAY_DURATION_MS) * PLAY_TICK_MS; + let current = value >= bounds.max ? bounds.min : value; + onCapChange(current); + playRef.current = setInterval(() => { + current += step; + if (current >= bounds.max) { + onCapChange(null); + setPlaying(false); + } else { + onCapChange(current); + } + }, PLAY_TICK_MS); + } + return () => { + if (playRef.current) clearInterval(playRef.current); + }; + // `value` is intentionally omitted: it advances every tick and would + // restart the interval; bounds ARE included so merged pages during + // playback extend the run instead of ending at a stale max. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [playing, bounds.min, bounds.max, timestamps]); + + return ( +
+ + { + setPlaying(false); + const next = Number(e.target.value); + onCapChange(next >= bounds.max ? null : next); + }} + className="h-1.5 w-28 min-w-0 flex-1 cursor-pointer appearance-none rounded-full bg-white/15 accent-(--brand) sm:w-48 md:w-72" + aria-label={t('time.scrub')} + /> + + {cap === null ? t('time.now') : format.dateTime(new Date(value), { dateStyle: 'medium' })} + + +
+ ); +} diff --git a/src/components/molecules/Header/Header.test.tsx b/src/components/molecules/Header/Header.test.tsx index 2237dff3a9..d63fe919ee 100644 --- a/src/components/molecules/Header/Header.test.tsx +++ b/src/components/molecules/Header/Header.test.tsx @@ -533,7 +533,7 @@ describe('Header Components', () => { // Home and Hot are public explore routes → real navigation links. const links = screen.getAllByRole('link'); - expect(links.map((link) => link.getAttribute('href'))).toEqual(['/home', '/hot']); + expect(links.map((link) => link.getAttribute('href'))).toEqual(['/home', '/hot', '/graph']); expect(screen.getByTestId('search-input')).toBeInTheDocument(); // All four nav icons are shown. diff --git a/src/components/molecules/Header/Header.test.tsx.snap b/src/components/molecules/Header/Header.test.tsx.snap index 76df213254..3c2904f581 100644 --- a/src/components/molecules/Header/Header.test.tsx.snap +++ b/src/components/molecules/Header/Header.test.tsx.snap @@ -290,6 +290,63 @@ exports[`Header Components - Snapshots > matches snapshot for HeaderNavigationBu + + + matches snapshot for HeaderNavigationBu + + + matches snapshot for HeaderSignIn 1`] = + + + { COLLECTIONS: '/collections', SETTINGS: '/settings', PROFILE: '/profile', + GRAPH: '/graph', }, SETTINGS_ROUTES: { ACCOUNT: '/settings/account', @@ -491,6 +492,7 @@ describe('MobileFooter', () => { '/search', '/hot', '/collections', + '/graph', '/settings/account', ]); expect(document.querySelector('.lucide-library')).toBeInTheDocument(); diff --git a/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap b/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap index 593c84a824..9d9558b0dc 100644 --- a/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap +++ b/src/components/molecules/MobileFooter/MobileFooter.test.tsx.snap @@ -173,6 +173,55 @@ exports[`MobileFooter - Snapshots > matches snapshot with custom className 1`] = /> + + + matches snapshot with default props 1`] = ` /> + + + matches snapshot with different active path /> + + + { + it('wires camera buttons and mode toggles', () => { + render(); + + fireEvent.click(document.querySelector('[data-cy="graph-zoom-in"]')!); + expect(props.onZoomIn).toHaveBeenCalled(); + + fireEvent.click(document.querySelector('[data-cy="graph-declutter"]')!); + expect(props.onToggleDeclutter).toHaveBeenCalled(); + + fireEvent.click(document.querySelector('[data-cy="graph-time-toggle"]')!); + expect(props.onToggleTimeMachine).toHaveBeenCalled(); + }); + + it('reflects mode state via aria-pressed and disables the time machine without timestamps', () => { + const { rerender } = render(); + expect(document.querySelector('[data-cy="graph-declutter"]')).toHaveAttribute('aria-pressed', 'true'); + expect(document.querySelector('[data-cy="graph-communities"]')).toHaveAttribute('aria-pressed', 'false'); + + rerender(); + expect(document.querySelector('[data-cy="graph-time-toggle"]')).toBeDisabled(); + }); +}); diff --git a/src/components/molecules/SocialGraphControls/SocialGraphControls.tsx b/src/components/molecules/SocialGraphControls/SocialGraphControls.tsx new file mode 100644 index 0000000000..4f90431005 --- /dev/null +++ b/src/components/molecules/SocialGraphControls/SocialGraphControls.tsx @@ -0,0 +1,110 @@ +'use client'; + +import { Crosshair, History, Maximize2, Pause, Pin, Play, Sparkles, Users, ZoomIn, ZoomOut } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { cn } from '@/libs/utils/utils'; +import type { SocialGraphControlsProps } from './SocialGraphControls.types'; + +/** + * SocialGraphControls + * + * Floating control stack: camera (zoom/fit/recenter), then the view modes + * (declutter, communities, time machine, physics, pins) and the share shot. + */ +export function SocialGraphControls({ + declutter, + onToggleDeclutter, + physicsPaused, + onTogglePhysics, + onReleasePins, + communitiesOn, + onToggleCommunities, + timeMachineOn, + timeMachineAvailable, + onToggleTimeMachine, + onZoomIn, + onZoomOut, + onFit, + onRecenter, + className, +}: SocialGraphControlsProps) { + const t = useTranslations('graph'); + + const camera = [ + { icon: ZoomIn, label: t('controls.zoomIn'), onClick: onZoomIn, dataCy: 'graph-zoom-in' }, + { icon: ZoomOut, label: t('controls.zoomOut'), onClick: onZoomOut, dataCy: 'graph-zoom-out' }, + { icon: Maximize2, label: t('controls.fit'), onClick: onFit, dataCy: 'graph-fit' }, + { icon: Crosshair, label: t('controls.recenter'), onClick: onRecenter, dataCy: 'graph-recenter' }, + ]; + + const modes = [ + { + icon: Sparkles, + label: t('controls.declutter'), + onClick: onToggleDeclutter, + active: declutter, + dataCy: 'graph-declutter', + }, + { + icon: Users, + label: t('controls.communities'), + onClick: onToggleCommunities, + active: communitiesOn, + dataCy: 'graph-communities', + }, + { + icon: History, + label: t('controls.timeMachine'), + onClick: onToggleTimeMachine, + active: timeMachineOn, + disabled: !timeMachineAvailable, + dataCy: 'graph-time-toggle', + }, + { + icon: physicsPaused ? Play : Pause, + label: physicsPaused ? t('controls.resumePhysics') : t('controls.pausePhysics'), + onClick: onTogglePhysics, + active: physicsPaused, + dataCy: 'graph-physics', + }, + { icon: Pin, label: t('controls.releasePins'), onClick: onReleasePins, dataCy: 'graph-release-pins' }, + ]; + + return ( +
+ {camera.map(({ icon: Icon, label, onClick, dataCy }) => ( + + ))} +
+ {modes.map(({ icon: Icon, label, onClick, active, disabled, dataCy }) => ( + + ))} +
+ ); +} diff --git a/src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts b/src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts new file mode 100644 index 0000000000..1882f7c978 --- /dev/null +++ b/src/components/molecules/SocialGraphControls/SocialGraphControls.types.ts @@ -0,0 +1,18 @@ +export interface SocialGraphControlsProps { + declutter: boolean; + onToggleDeclutter: () => void; + physicsPaused: boolean; + onTogglePhysics: () => void; + onReleasePins: () => void; + communitiesOn: boolean; + onToggleCommunities: () => void; + timeMachineOn: boolean; + /** Disabled when the graph has no timestamps to scrub over */ + timeMachineAvailable: boolean; + onToggleTimeMachine: () => void; + onZoomIn: () => void; + onZoomOut: () => void; + onFit: () => void; + onRecenter: () => void; + className?: string; +} diff --git a/src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx new file mode 100644 index 0000000000..e51bcfef91 --- /dev/null +++ b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.test.tsx @@ -0,0 +1,55 @@ +import { fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { SocialGraphLegend } from './SocialGraphLegend'; + +const props = { + classCounts: new Map([ + ['self', 1], + ['friend', 8], + ['post', 10], + ]), + hiddenClasses: new Set(['post']), + onHoverClass: vi.fn(), + onToggleClass: vi.fn(), +}; + +describe('SocialGraphLegend', () => { + it('shows live counts, spotlights on hover, and toggles on click', () => { + render(); + + // Counts render next to their rows + expect(screen.getByText('8')).toBeInTheDocument(); + + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-friend"]')!); + expect(props.onHoverClass).toHaveBeenCalledWith('friend'); + + fireEvent.click(document.querySelector('[data-cy="graph-legend-friend"]')!); + expect(props.onToggleClass).toHaveBeenCalledWith('friend'); + + // Hidden classes read as off + expect(document.querySelector('[data-cy="graph-legend-post"]')).toHaveAttribute('aria-pressed', 'false'); + }); + + it('hides edge rows by default', () => { + render(); + expect(document.querySelector('[data-cy="graph-legend-edge-fresh"]')).toBeNull(); + expect(document.querySelector('[data-cy="graph-legend-edge-intra"]')).toBeNull(); + }); + + it('shows edge rows per mode and spotlights matching edges on hover', () => { + const onHoverEdges = vi.fn(); + render(); + + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-edge-fresh"]')!); + expect(onHoverEdges).toHaveBeenCalledWith('fresh'); + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-edge-intra"]')!); + expect(onHoverEdges).toHaveBeenCalledWith('intra'); + fireEvent.mouseEnter(document.querySelector('[data-cy="graph-legend-edge-bridge"]')!); + expect(onHoverEdges).toHaveBeenCalledWith('bridge'); + + // Leaving the legend clears the edge spotlight too + fireEvent.mouseLeave(document.querySelector('[data-cy="graph-legend"]')!); + expect(onHoverEdges).toHaveBeenLastCalledWith(null); + }); +}); diff --git a/src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx new file mode 100644 index 0000000000..a52d4b00a4 --- /dev/null +++ b/src/components/molecules/SocialGraphLegend/SocialGraphLegend.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { useState } from 'react'; +import { ChevronDown, ChevronUp } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import type { HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { cn } from '@/libs/utils/utils'; + +/** Edge-encoding rows: recent follows, intra-community links, bridges. */ +export type EdgeLegendKind = 'fresh' | 'intra' | 'bridge'; + +export interface SocialGraphLegendProps { + classCounts: Map; + hiddenClasses: Set; + /** Hover intent on a row: spotlight that class on the canvas */ + onHoverClass: (cls: HideableClass | null) => void; + /** Click on a row: hide/show that class */ + onToggleClass: (cls: HideableClass) => void; + /** Show the follow-recency gradient row (the graph has timestamped follows) */ + showRecency?: boolean; + /** Show the community tint/bridge rows (communities mode is on) */ + communitiesOn?: boolean; + /** Hover intent on an edge row: spotlight matching edges on the canvas */ + onHoverEdges?: (kind: EdgeLegendKind | null) => void; + className?: string; +} + +const RELATIONSHIP_ROWS: { key: HideableClass; swatch: string }[] = [ + { key: 'self', swatch: 'bg-brand rounded-full' }, + { key: 'friend', swatch: 'bg-(--chart-2) rounded-full' }, + { key: 'following', swatch: 'bg-(--chart-3) rounded-full' }, + { key: 'follower', swatch: 'bg-(--chart-1) rounded-full' }, + { key: 'extended', swatch: 'bg-muted-foreground rounded-full' }, + { key: 'post', swatch: 'rounded-[3px] border border-muted-foreground bg-white/10' }, + { key: 'tag', swatch: 'w-4 rounded-full border border-brand bg-brand/20' }, +]; + +/** + * SocialGraphLegend + * + * The legend IS the filter: every row shows a live count, hovering a row + * spotlights that class on the canvas, clicking hides it. One surface for + * reading the graph and shaping it. + */ +export function SocialGraphLegend({ + classCounts, + hiddenClasses, + onHoverClass, + onToggleClass, + showRecency = false, + communitiesOn = false, + onHoverEdges, + className, +}: SocialGraphLegendProps) { + const t = useTranslations('graph'); + const [open, setOpen] = useState(true); + + return ( +
{ + onHoverClass(null); + onHoverEdges?.(null); + }} + > +
+ + {t('legend.title')} + + +
+ {open && ( +
+ {RELATIONSHIP_ROWS.map(({ key, swatch }) => { + const hidden = hiddenClasses.has(key); + const count = classCounts.get(key) ?? 0; + return ( + + ); + })} + {(showRecency || communitiesOn) && ( + <> +
+ {showRecency && ( +
onHoverEdges?.('fresh')} + className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-white/10" + title={`${t('legend.old')} → ${t('legend.new')}`} + data-cy="graph-legend-edge-fresh" + > + + + {t('legend.followAge')} + + + {t('legend.old')} → {t('legend.new')} + +
+ )} + {communitiesOn && ( + <> +
onHoverEdges?.('intra')} + className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-white/10" + data-cy="graph-legend-edge-intra" + > + + + {t('legend.sameCommunity')} + +
+
onHoverEdges?.('bridge')} + className="flex items-center gap-2 rounded-lg px-1.5 py-1 transition-colors hover:bg-white/10" + data-cy="graph-legend-edge-bridge" + > + + + {t('legend.bridge')} + +
+ + )} + + )} +
+ )} +
+ ); +} diff --git a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx b/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx index 141c9bc138..232c65ca3a 100644 --- a/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx +++ b/src/components/molecules/UserInfoPopover/components/UserInfoPopoverFollowButton/UserInfoPopoverFollowButton.tsx @@ -1,47 +1,6 @@ -'use client'; - -import { Check, Loader2, UserMinus, UserRoundPlus } from 'lucide-react'; -import { useTranslations } from 'next-intl'; -import { Button } from '@/atoms/Button/Button'; -import { Typography } from '@/atoms/Typography/Typography'; - -interface UserInfoPopoverFollowButtonProps { - isFollowing: boolean; - isLoading: boolean; - onClick: (e: React.MouseEvent) => void; -} -export function UserInfoPopoverFollowButton({ isFollowing, isLoading, onClick }: UserInfoPopoverFollowButtonProps) { - const t = useTranslations('userList'); - return ( - - ); -} +/** + * Thin re-export: the follow button was promoted to the shared FollowButton + * molecule (used beyond the popover, e.g. the graph node panel); this alias + * keeps existing popover imports stable. + */ +export { FollowButton as UserInfoPopoverFollowButton } from '@/molecules/FollowButton/FollowButton'; diff --git a/src/components/organisms/ProfileHoverCard/ProfileHoverCard.test.tsx b/src/components/organisms/ProfileHoverCard/ProfileHoverCard.test.tsx new file mode 100644 index 0000000000..75e672dc2c --- /dev/null +++ b/src/components/organisms/ProfileHoverCard/ProfileHoverCard.test.tsx @@ -0,0 +1,45 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { Pubky } from '@/models/models.types'; +import { ProfileHoverCard } from './ProfileHoverCard'; + +// The shared popover content pulls in the full reactive user data stack; +// stub it, the card's job is anchoring and wiring props through. +vi.mock('@/molecules/UserInfoPopover/components/UserInfoPopoverContent/UserInfoPopoverContent', () => ({ + UserInfoPopoverContent: ({ userId, userName }: { userId: string; userName: string }) => ( +
+ {userId}:{userName} +
+ ), +})); + +const PUBKY = 'o1gg96ewuojmopcjbz8895478wdtxtzzber7aezq6ror5a91j7dy' as Pubky; + +function Harness({ open, userName }: { open: boolean; userName?: string }) { + return ( +
+ +
+ ); +} + +describe('ProfileHoverCard', () => { + it('renders the shared user info content anchored to the canvas point', () => { + render(); + + expect(document.querySelector('[data-cy="graph-hover-card"]')).toBeInTheDocument(); + expect(screen.getByTestId('user-info-popover-content')).toHaveTextContent(`${PUBKY}:Alice`); + }); + + it('falls back to the formatted public key as the display name', () => { + render(); + + expect(screen.getByTestId('user-info-popover-content')).toHaveTextContent(/o1gg.*\.\.\./); + }); + + it('renders nothing when closed', () => { + render(); + + expect(document.querySelector('[data-cy="graph-hover-card"]')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/ProfileHoverCard/ProfileHoverCard.tsx b/src/components/organisms/ProfileHoverCard/ProfileHoverCard.tsx new file mode 100644 index 0000000000..5324a7ffbf --- /dev/null +++ b/src/components/organisms/ProfileHoverCard/ProfileHoverCard.tsx @@ -0,0 +1,64 @@ +'use client'; + +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { cn, formatPublicKey } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import { CanvasAnchoredPopover } from '@/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover'; +import { UserInfoPopoverContent } from '@/molecules/UserInfoPopover/components/UserInfoPopoverContent/UserInfoPopoverContent'; + +export interface ProfileHoverCardProps { + pubky: Pubky; + /** Display name when the caller already has one (falls back to the key) */ + userName?: string; + /** Early avatar fallback while the reactive profile loads */ + avatarUrl?: string; + /** Controlled visibility; hover intent lives in the caller */ + open: boolean; + /** Anchor point relative to the positioned container; the caller feeds + * fresh coordinates per frame so the card tracks the node */ + x: number; + y: number; + /** Keeps the card alive while the pointer is over it */ + onPointerEnter?: () => void; + onPointerLeave?: () => void; + className?: string; +} + +/** + * ProfileHoverCard + * + * Hover-intent profile preview for the graph canvas: the shared + * UserInfoPopoverContent (reactive bio, counts, follow button, profile + * links) riding the canvas-anchored positioner, so it spawns fully visible + * next to the hovered node and follows it through pan, zoom, and drags. + */ +export function ProfileHoverCard({ + pubky, + userName, + avatarUrl, + open, + x, + y, + onPointerEnter, + onPointerLeave, + className, +}: ProfileHoverCardProps) { + if (!open) return null; + return ( + + + + ); +} diff --git a/src/components/organisms/SearchInput/SearchInput.test.tsx b/src/components/organisms/SearchInput/SearchInput.test.tsx index a740607db3..845cac3dc7 100644 --- a/src/components/organisms/SearchInput/SearchInput.test.tsx +++ b/src/components/organisms/SearchInput/SearchInput.test.tsx @@ -5,6 +5,7 @@ import { useSearchAutocomplete } from '@/hooks/useSearchAutocomplete/useSearchAu import { useSearchInput } from '@/hooks/useSearchInput/useSearchInput'; import { useTagSearch } from '@/hooks/useTagSearch/useTagSearch'; import type { Pubky } from '@/models/models.types'; +import { useGraphStore } from '@/stores/graph/graph.store'; import { useSearchStore } from '@/stores/search/search.store'; import { SearchInput } from './SearchInput'; @@ -525,6 +526,35 @@ describe('SearchInput', () => { expect(setFocus).toHaveBeenCalledWith(false); expect(mockPush).toHaveBeenCalledWith('/profile/user123'); }); + + it('hands user picks to the graph instead of navigating while on the graph page', () => { + mockPathname.mockReturnValue('/graph'); + mockPush.mockClear(); + vi.mocked(useSearchInput).mockReturnValue({ + inputValue: 'satoshi', + isFocused: true, + containerRef: { current: null }, + inputRef: { current: null }, + handleInputChange: vi.fn(), + handleKeyDown: vi.fn(), + handleFocus: vi.fn(), + clearInputValue: vi.fn(), + setFocus: vi.fn(), + }); + vi.mocked(useSearchAutocomplete).mockReturnValue({ + tags: [], + users: [{ id: 'user123', name: 'Satoshi' }], + isLoading: false, + }); + + render(); + fireEvent.click(screen.getByTestId('autocomplete-user-user123')); + + expect(mockPush).not.toHaveBeenCalled(); + expect(useGraphStore.getState().searchTarget).toEqual({ kind: 'user', pubky: 'user123' }); + useGraphStore.getState().clearSearchTarget(); + mockPathname.mockReturnValue('/home'); + }); }); describe('Active Tag Removal', () => { diff --git a/src/components/organisms/SearchInput/SearchInput.tsx b/src/components/organisms/SearchInput/SearchInput.tsx index f6fb942930..575fb69089 100644 --- a/src/components/organisms/SearchInput/SearchInput.tsx +++ b/src/components/organisms/SearchInput/SearchInput.tsx @@ -17,6 +17,7 @@ import { SearchInputBar } from '@/molecules/SearchInputBar/SearchInputBar'; import { SearchSuggestions } from '@/molecules/SearchSuggestions/SearchSuggestions'; import { toast } from '@/molecules/Toaster/use-toast'; import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; import { useSearchStore } from '@/stores/search/search.store'; import { SearchInputProps } from './SearchInput.types'; import { parseTagsFromUrl } from './SearchInput.utils'; @@ -32,12 +33,22 @@ export function SearchInput({ autoFocus = false }: SearchInputProps) { const currentUserPubky = useAuthStore((state) => state.currentUserPubky); const isMobile = useIsMobile(); + // On the graph page this bar drives the canvas instead of navigating: picks + // are handed to the graph via the store, so desktop needs no second field + const isGraphPage = pathname?.startsWith(APP_ROUTES.GRAPH) ?? false; + const handleEnter = (value: string) => { if (!isValidTagLabel(value.trim().toLowerCase())) { toast({ variant: 'error', description: t('invalidTag') }); return false; } + if (isGraphPage) { + useGraphStore.getState().requestSearch({ kind: 'tag', label: value.trim().toLowerCase() }); + setFocus(false); + return; + } + addTagToSearch(value, { addToRecent: true }); if (pathname !== APP_ROUTES.SEARCH) { setFocus(false); @@ -74,10 +85,21 @@ export function SearchInput({ autoFocus = false }: SearchInputProps) { addUser(userId); clearInputValue(); setFocus(false); + if (isGraphPage) { + useGraphStore.getState().requestSearch({ kind: 'user', pubky: userId }); + return; + } router.push(getUserProfileUrl(userId, currentUserPubky)); }; const handleTagClick = (tag: string) => { + if (isGraphPage) { + useGraphStore.getState().requestSearch({ kind: 'tag', label: tag }); + clearInputValue(); + setFocus(false); + return; + } + addTagToSearch(tag, { addToRecent: true }); clearInputValue(); diff --git a/src/components/organisms/SocialGraph/SocialGraph.test.tsx b/src/components/organisms/SocialGraph/SocialGraph.test.tsx new file mode 100644 index 0000000000..7b77208c97 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.test.tsx @@ -0,0 +1,198 @@ +import { createRef } from 'react'; +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { SocialGraphVisualEdge } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { SocialGraph } from './SocialGraph'; +import type { SocialGraphHandle } from './SocialGraph.types'; + +// jsdom has no canvas: the force-graph engine is replaced by a stub that +// records the graph data it receives. +const receivedProps: Record[] = []; + +vi.mock('next/dynamic', () => ({ + default: () => { + const MockForceGraph = (props: Record) => { + receivedProps.push(props); + return
; + }; + return MockForceGraph; + }, +})); + +vi.mock('usehooks-ts', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useResizeObserver: () => ({ width: 800, height: 600 }), + }; +}); + +vi.mock('@/controllers/file/file', () => ({ + FileController: { getAvatarUrl: vi.fn(() => 'https://cdn.example/avatar') }, +})); + +const nodes: NexusGraphNode[] = [ + { kind: 'user', id: 'user:me', pubky: 'me', name: 'Me', image: null }, + { kind: 'user', id: 'user:friend', pubky: 'friend', name: 'Friend', image: null }, +]; +const edges: SocialGraphVisualEdge[] = [{ source: 'user:me', target: 'user:friend', type: 'FRIEND' }]; + +describe('SocialGraph', () => { + it('renders the canvas wrapper and passes graph data to the engine', () => { + const ref = createRef(); + render( + , + ); + + expect(screen.getByTestId('force-graph-stub')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="social-graph"]')).toBeInTheDocument(); + + const props = receivedProps.at(-1) as { graphData: { nodes: unknown[]; links: unknown[] } }; + expect(props.graphData.nodes).toHaveLength(2); + expect(props.graphData.links).toHaveLength(1); + // Node objects are passed by reference so the simulation keeps positions + expect(props.graphData.nodes[0]).toBe(nodes[0]); + // Link objects are copies so the engine's endpoint mutation stays contained + expect(props.graphData.links[0]).not.toBe(edges[0]); + + // Edges paint a fat pointer area and advertise clickability on hover + const interactionProps = receivedProps.at(-1) as { + linkPointerAreaPaint: unknown; + onLinkHover: unknown; + }; + expect(interactionProps.linkPointerAreaPaint).toEqual(expect.any(Function)); + expect(interactionProps.onLinkHover).toEqual(expect.any(Function)); + + // The imperative camera handle is exposed + expect(ref.current).toMatchObject({ + zoomIn: expect.any(Function), + zoomOut: expect.any(Function), + fit: expect.any(Function), + centerOn: expect.any(Function), + setPaused: expect.any(Function), + releasePins: expect.any(Function), + }); + }); + + it('colors focus edges by relationship and neighbor edges by recency', () => { + const manyNodes: NexusGraphNode[] = [ + ...nodes, + { kind: 'user', id: 'user:a', pubky: 'a', name: 'A', image: null }, + { kind: 'user', id: 'user:b', pubky: 'b', name: 'B', image: null }, + ]; + const manyEdges: SocialGraphVisualEdge[] = [ + { source: 'user:me', target: 'user:friend', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:a', target: 'user:b', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:b', target: 'user:a', type: 'FOLLOWS', indexed_at: 1000 }, + ]; + render( + , + ); + const props = receivedProps.at(-1) as { linkColor: (link: unknown) => string }; + const focusEdge = props.linkColor({ source: 'user:me', target: 'user:friend', type: 'FOLLOWS' }); + const oldEdge = props.linkColor({ source: 'user:a', target: 'user:b', type: 'FOLLOWS', indexed_at: 100 }); + const freshEdge = props.linkColor({ source: 'user:b', target: 'user:a', type: 'FOLLOWS', indexed_at: 1000 }); + // Focus edge keeps the relationship palette; neighbor edges do not + expect(focusEdge).not.toBe(oldEdge); + // Recency separates neighbor edges: fresh is more opaque than old + const alphaOf = (rgba: string) => Number(rgba.match(/[\d.]+/g)!.at(-1)); + expect(alphaOf(freshEdge)).toBeGreaterThan(alphaOf(oldEdge)); + }); + + it('dims links outside an explicit edge spotlight', () => { + const manyEdges: SocialGraphVisualEdge[] = [ + { source: 'user:me', target: 'user:friend', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:friend', target: 'user:me', type: 'FOLLOWS', indexed_at: 900 }, + ]; + render( + , + ); + const props = receivedProps.at(-1) as { linkColor: (link: unknown) => string }; + const alphaOf = (rgba: string) => Number(rgba.match(/[\d.]+/g)!.at(-1)); + const dimmed = props.linkColor({ source: 'user:me', target: 'user:friend', type: 'FOLLOWS', indexed_at: 100 }); + const lit = props.linkColor({ source: 'user:friend', target: 'user:me', type: 'FOLLOWS', indexed_at: 900 }); + expect(alphaOf(dimmed)).toBeLessThanOrEqual(0.05); + expect(alphaOf(lit)).toBeGreaterThan(0.1); + }); + + it('tints intra-community edges and keeps bridges neutral when communities are on', () => { + const manyNodes: NexusGraphNode[] = [ + ...nodes, + { kind: 'user', id: 'user:a', pubky: 'a', name: 'A', image: null }, + { kind: 'user', id: 'user:b', pubky: 'b', name: 'B', image: null }, + ]; + const manyEdges: SocialGraphVisualEdge[] = [ + { source: 'user:a', target: 'user:b', type: 'FOLLOWS', indexed_at: 100 }, + { source: 'user:friend', target: 'user:a', type: 'FOLLOWS', indexed_at: 100 }, + ]; + render( + , + ); + const props = receivedProps.at(-1) as { linkColor: (link: unknown) => string }; + const intra = props.linkColor({ source: 'user:a', target: 'user:b', type: 'FOLLOWS' }); + const bridge = props.linkColor({ source: 'user:friend', target: 'user:a', type: 'FOLLOWS' }); + expect(intra).not.toBe(bridge); + expect(bridge).toBe('rgba(245, 245, 255, 0.6)'); + }); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.theme.test.ts b/src/components/organisms/SocialGraph/SocialGraph.theme.test.ts new file mode 100644 index 0000000000..883949c1bd --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.theme.test.ts @@ -0,0 +1,52 @@ +import { describe, expect, it } from 'vitest'; +import { edgeRecencyColor, liftForDarkCanvas } from './SocialGraph.theme'; + +/** Relative lightness as HSL L, from a #rrggbb string. */ +function lightnessOf(hex: string): number { + const r = parseInt(hex.slice(1, 3), 16) / 255; + const g = parseInt(hex.slice(3, 5), 16) / 255; + const b = parseInt(hex.slice(5, 7), 16) / 255; + return (Math.max(r, g, b) + Math.min(r, g, b)) / 2; +} + +describe('liftForDarkCanvas', () => { + it('lifts dark colors to a readable lightness while keeping the hue family', () => { + const lifted = liftForDarkCanvas('#00008b'); + expect(lightnessOf(lifted)).toBeGreaterThanOrEqual(0.54); + // Still blue-dominant + const [r, g, b] = [lifted.slice(1, 3), lifted.slice(3, 5), lifted.slice(5, 7)].map((c) => parseInt(c, 16)); + expect(b).toBeGreaterThan(r); + expect(b).toBeGreaterThan(g); + }); + + it('leaves already-bright colors untouched', () => { + expect(liftForDarkCanvas('#c8ff00')).toBe('#c8ff00'); + expect(liftForDarkCanvas('#ff98a0')).toBe('#ff98a0'); + }); + + it('passes through malformed input unchanged', () => { + expect(liftForDarkCanvas('not-a-color')).toBe('not-a-color'); + }); +}); + +describe('edgeRecencyColor', () => { + const channels = (rgba: string) => rgba.match(/[\d.]+/g)!.map(Number); + + it('fades old edges and brightens fresh ones', () => { + const old = channels(edgeRecencyColor(0, false)); + const fresh = channels(edgeRecencyColor(1, false)); + // Fresh is brighter and more opaque than old + expect(fresh[0] + fresh[1] + fresh[2]).toBeGreaterThan(old[0] + old[1] + old[2]); + expect(fresh[3]).toBeGreaterThan(old[3]); + expect(old[3]).toBeGreaterThan(0.05); + }); + + it('clamps t outside [0,1]', () => { + expect(edgeRecencyColor(-1, false)).toBe(edgeRecencyColor(0, false)); + expect(edgeRecencyColor(2, false)).toBe(edgeRecencyColor(1, false)); + }); + + it('dimmed edges collapse to the spotlight alpha regardless of age', () => { + expect(channels(edgeRecencyColor(1, true))[3]).toBeLessThanOrEqual(0.05); + }); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.theme.ts b/src/components/organisms/SocialGraph/SocialGraph.theme.ts new file mode 100644 index 0000000000..694a3c23c0 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.theme.ts @@ -0,0 +1,118 @@ +/** + * Canvas color resolution for the social graph. + * + * The 2D canvas cannot consume CSS variables, so tokens are read once from + * the document root and normalized to hex (the paint code derives alpha + * variants through hexToRgba, which only parses hex), with hex fallbacks + * mirroring globals.css in the shared COLORS config. + */ + +import { COLORS } from '@/config/theme'; +import { cssColorToHex } from '@/libs/utils/utils'; + +export type GraphTheme = { + self: string; + friend: string; + following: string; + follower: string; + extended: string; + post: string; + edgeMuted: string; + label: string; + halo: string; +}; + +/** Hex fallbacks approximating the OKLCH tokens in globals.css */ +export const GRAPH_FALLBACK_COLORS: GraphTheme = { ...COLORS.graph }; + +const TOKEN_BY_KEY: Partial> = { + self: '--brand', + friend: '--chart-2', + following: '--chart-3', + follower: '--chart-1', +}; + +/** Colors below this perceived luminance get lifted before canvas painting. */ +const MIN_CANVAS_LUMINANCE = 0.35; +/** Target HSL lightness for lifted colors. */ +const LIFTED_LIGHTNESS = 0.55; + +/** + * Lifts a #rrggbb color to a readable brightness so label-derived hues (which + * can hash to near-black navies and maroons) stay visible on the dark canvas. + * Gated on perceived luminance, not HSL lightness: saturated greens/yellows + * read bright at L=0.5 while blues need the lift. Bright colors and non-hex + * input pass through unchanged. + */ +export function liftForDarkCanvas(hex: string): string { + const match = /^#([0-9a-f]{6})$/i.exec(hex); + if (!match) return hex; + const r = parseInt(match[1].slice(0, 2), 16) / 255; + const g = parseInt(match[1].slice(2, 4), 16) / 255; + const b = parseInt(match[1].slice(4, 6), 16) / 255; + const luminance = 0.2126 * r + 0.7152 * g + 0.0722 * b; + if (luminance >= MIN_CANVAS_LUMINANCE) return hex; + const max = Math.max(r, g, b); + const min = Math.min(r, g, b); + const lightness = (max + min) / 2; + + // hex -> HSL, clamp L, HSL -> hex + const delta = max - min; + const saturation = delta === 0 ? 0 : delta / (1 - Math.abs(2 * lightness - 1)); + let hue = 0; + if (delta !== 0) { + if (max === r) hue = ((g - b) / delta) % 6; + else if (max === g) hue = (b - r) / delta + 2; + else hue = (r - g) / delta + 4; + hue *= 60; + if (hue < 0) hue += 360; + } + const l = Math.max(lightness, LIFTED_LIGHTNESS); + const c = (1 - Math.abs(2 * l - 1)) * saturation; + const x = c * (1 - Math.abs(((hue / 60) % 2) - 1)); + const m = l - c / 2; + let [nr, ng, nb] = [0, 0, 0]; + if (hue < 60) [nr, ng, nb] = [c, x, 0]; + else if (hue < 120) [nr, ng, nb] = [x, c, 0]; + else if (hue < 180) [nr, ng, nb] = [0, c, x]; + else if (hue < 240) [nr, ng, nb] = [0, x, c]; + else if (hue < 300) [nr, ng, nb] = [x, 0, c]; + else [nr, ng, nb] = [c, 0, x]; + const toHex = (v: number) => + Math.round((v + m) * 255) + .toString(16) + .padStart(2, '0'); + return `#${toHex(nr)}${toHex(ng)}${toHex(nb)}`; +} + +// Recency ramp endpoints for neighbor-to-neighbor follow edges: old +// connections recede into cool gray, fresh ones glow warm white +const EDGE_OLD = { r: 108, g: 110, b: 122, a: 0.12 }; +const EDGE_FRESH = { r: 246, g: 240, b: 222, a: 0.62 }; + +/** + * Color for a follow edge by normalized recency t (0 = oldest in view, + * 1 = freshest). Spotlight-dimmed edges collapse to the shared dim alpha. + */ +export function edgeRecencyColor(t: number, dimmed: boolean): string { + const clamped = Math.min(1, Math.max(0, t)); + const r = Math.round(EDGE_OLD.r + (EDGE_FRESH.r - EDGE_OLD.r) * clamped); + const g = Math.round(EDGE_OLD.g + (EDGE_FRESH.g - EDGE_OLD.g) * clamped); + const b = Math.round(EDGE_OLD.b + (EDGE_FRESH.b - EDGE_OLD.b) * clamped); + const a = dimmed ? 0.04 : EDGE_OLD.a + (EDGE_FRESH.a - EDGE_OLD.a) * clamped; + return `rgba(${r}, ${g}, ${b}, ${Number(a.toFixed(3))})`; +} + +export function resolveGraphTheme(): GraphTheme { + if (typeof window === 'undefined') return GRAPH_FALLBACK_COLORS; + const style = getComputedStyle(document.documentElement); + const theme = { ...GRAPH_FALLBACK_COLORS }; + for (const [key, token] of Object.entries(TOKEN_BY_KEY) as [keyof GraphTheme, string][]) { + // Anything non-normalizable (no canvas, out-of-gamut serialization, + // parse failure) keeps the fallback + const normalized = cssColorToHex(style.getPropertyValue(token).trim()); + if (normalized) theme[key] = normalized; + } + theme.halo = theme.self; + return theme; +} diff --git a/src/components/organisms/SocialGraph/SocialGraph.tsx b/src/components/organisms/SocialGraph/SocialGraph.tsx new file mode 100644 index 0000000000..a659785f82 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.tsx @@ -0,0 +1,856 @@ +'use client'; + +import { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'; +import dynamic from 'next/dynamic'; +import type { ForceGraphMethods, LinkObject, NodeObject } from 'react-force-graph-2d'; +import { useResizeObserver } from 'usehooks-ts'; +import { Skeleton } from '@/atoms/Skeleton/Skeleton'; +import { FileController } from '@/controllers/file/file'; +import { adjacencyOf, edgeKey, type SocialGraphVisualEdge } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { cn, generateRandomColor, hexToRgba } from '@/libs/utils/utils'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { + edgeRecencyColor, + GRAPH_FALLBACK_COLORS, + type GraphTheme, + liftForDarkCanvas, + resolveGraphTheme, +} from './SocialGraph.theme'; +import type { SocialGraphHandle, SocialGraphProps } from './SocialGraph.types'; + +const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { + ssr: false, + loading: () => , +}); + +type CanvasNode = NodeObject & + NexusGraphNode & { + __bornAt?: number; + __pinned?: boolean; + fx?: number; + fy?: number; + }; +type CanvasLink = LinkObject & SocialGraphVisualEdge; + +const DOUBLE_CLICK_MS = 350; +const DIM_ALPHA = 0.12; +const PULSE_MS = 900; +const HOVER_INTENT_MS = 350; + +// Avatar bitmaps are shared across renders and node instances; the canvas +// repaints continuously (autoPauseRedraw=false), so late loads pop in without +// bookkeeping. Failed loads are remembered to avoid re-fetch storms. +const avatarCache = new Map(); + +function avatarImage(pubky: string, hasImage: boolean): HTMLImageElement | null { + if (!hasImage) return null; + const cached = avatarCache.get(pubky); + if (cached === 'error') return null; + if (cached) return cached.complete && cached.naturalWidth > 0 ? cached : null; + const img = new Image(); + img.crossOrigin = 'anonymous'; + img.onerror = () => avatarCache.set(pubky, 'error'); + img.src = FileController.getAvatarUrl(pubky); + avatarCache.set(pubky, img); + return null; +} + +const endpointId = (end: string | number | NodeObject | undefined): string => + typeof end === 'object' && end !== null ? String(end.id) : String(end ?? ''); + +/** edgeKey over a materialized link (endpoints may be node objects). */ +const linkKeyOf = (link: CanvasLink): string => + edgeKey({ source: endpointId(link.source), target: endpointId(link.target), type: link.type, label: link.label }); + +/** Deterministic tint per community: chart tokens first, generated colors after. */ +const COMMUNITY_BASE = ['#4B48E5', '#31E581', '#4FD7E8', '#E24BCB', '#E5484B', '#E8A33D']; +const communityColor = (index: number): string => COMMUNITY_BASE[index] ?? generateRandomColor(`community-${index}`); + +/** Hash color for a tag label, lifted so dark hues stay readable on canvas. */ +const labelColor = (label: string): string => liftForDarkCanvas(generateRandomColor(label)); + +/** + * SocialGraph + * + * The force-directed canvas: users as avatar discs ringed by their + * relationship to the focused user, tags as colored pills, posts as muted + * squares; birth pulses, spotlight dimming, path particles, and community + * halos on top. The only module that imports react-force-graph-2d, so the + * rendering engine stays swappable. + */ +export const SocialGraph = forwardRef(function SocialGraph( + { + nodes, + edges, + focusId, + selectedId, + relationships, + spotlight, + spotlightEdges = null, + pathIds, + communities, + communityLabels, + onNodeClick, + onNodeExpand, + onBackgroundClick, + onLinkClick, + onUserHover, + className, + }, + ref, +) { + const containerRef = useRef(null); + const graphRef = useRef(undefined); + const { width = 0, height = 0 } = useResizeObserver({ + ref: containerRef as React.RefObject, + box: 'border-box', + }); + const [theme, setTheme] = useState(GRAPH_FALLBACK_COLORS); + // Coarse PRIMARY pointers get fatter hit targets and no hover-intent + // popover (the inspector panel is the touch affordance). Deliberately not + // useIsTouchDevice: that reports true for mouse-driven touchscreen laptops + // (maxTouchPoints > 0) and would disable the hover card there. + const coarsePointer = useMemo(() => window.matchMedia?.('(pointer: coarse)')?.matches ?? false, []); + // Flips once the dynamically imported engine mounts and the ref is live; + // effects keyed on it would otherwise fire against an empty ref + const [engineReady, setEngineReady] = useState(false); + const [hoverId, setHoverId] = useState(null); + const lastClick = useRef<{ id: string; at: number }>({ id: '', at: 0 }); + const didInitialFit = useRef(false); + const focusPulseAt = useRef(0); + const hoverTimer = useRef | null>(null); + + useEffect(() => { + setTheme(resolveGraphTheme()); + return () => { + if (hoverTimer.current) clearTimeout(hoverTimer.current); + }; + }, []); + + // Re-fit the camera when the view re-centers, and pulse the new focus + useEffect(() => { + didInitialFit.current = false; + focusPulseAt.current = Date.now(); + }, [focusId]); + + // force-graph mutates link endpoints into node references, so it cannot be + // handed the pipeline's edge objects directly. The copies are CACHED by + // edge identity and reused across recomputes: the engine registers every + // object it has never seen in a finite hit-test color registry (~262k + // entries, then permanently full), so re-materializing ~1500 links on every + // legend toggle or time-machine tick exhausts it within minutes and nodes + // silently stop being clickable. Reuse keeps registrations near zero. + // Node objects are passed by reference on purpose (the simulation stores + // coordinates on them, which keeps layout across merges). + // Not a React ref on purpose (refs must not be read during render); a + // per-mount Map whose entries accumulate (bounded by distinct edges seen) + const [linkCache] = useState(() => new Map()); + const graphData = useMemo(() => { + // Object-identity set of the current nodes, to validate resolved endpoints + const nodeSet = new Set(nodes); + const links = edges.map((edge) => { + const key = edgeKey(edge); + const cached = linkCache.get(key); + if (cached) { + // NEVER reset resolved endpoints on a cached link: the simulation + // still holds these objects and mutating them mid-flight corrupts + // the running layout (d3 then throws "node not found"). Reuse only + // when both endpoints still belong to the current node set; a link + // whose node was evicted and re-added gets a fresh copy instead. + const sourceOk = typeof cached.source === 'object' ? nodeSet.has(cached.source) : cached.source === edge.source; + const targetOk = typeof cached.target === 'object' ? nodeSet.has(cached.target) : cached.target === edge.target; + if (sourceOk && targetOk) { + cached.type = edge.type; + cached.label = edge.label; + cached.labels = edge.labels; + cached.indexed_at = edge.indexed_at; + return cached; + } + } + const link = { ...edge } as CanvasLink; + linkCache.set(key, link); + return link; + }); + return { nodes: nodes as CanvasNode[], links }; + }, [nodes, edges, linkCache]); + + // Dense follow clusters collapse under the default forces; stronger + // repulsion and a longer link rest length keep avatars readable. + useEffect(() => { + const fg = graphRef.current; + if (!fg) return; + fg.d3Force('charge')?.strength(-140); + const link = fg.d3Force('link') as { distance?: (d: number) => void } | undefined; + link?.distance?.(45); + }, [graphData, engineReady]); + + const degreeById = useMemo(() => { + const map = new Map(); + for (const edge of edges) { + map.set(edge.source, (map.get(edge.source) ?? 0) + 1); + map.set(edge.target, (map.get(edge.target) ?? 0) + 1); + } + return map; + }, [edges]); + + const hoverNeighbors = useMemo(() => (hoverId ? adjacencyOf(hoverId, edges).add(hoverId) : null), [hoverId, edges]); + // One dimming mechanism: an explicit spotlight outranks hover adjacency + const highlightSet = spotlight ?? hoverNeighbors; + + const pathIdSet = useMemo(() => (pathIds ? new Set(pathIds) : null), [pathIds]); + + // Unordered "a|b" pair keys of the traced path, for particles and glow + const pathPairs = useMemo(() => { + if (!pathIds || pathIds.length < 2) return null; + const pairs = new Set(); + for (let i = 0; i < pathIds.length - 1; i++) { + pairs.add([pathIds[i], pathIds[i + 1]].sort().join('|')); + } + return pairs; + }, [pathIds]); + + const isPathLink = useCallback( + (link: CanvasLink): boolean => { + if (!pathPairs) return false; + return pathPairs.has([endpointId(link.source), endpointId(link.target)].sort().join('|')); + }, + [pathPairs], + ); + + const relationshipColor = useCallback( + (nodeId: string): string => { + switch (relationships.get(nodeId)) { + case 'self': + return theme.self; + case 'friend': + return theme.friend; + case 'following': + return theme.following; + case 'follower': + return theme.follower; + default: + return theme.extended; + } + }, + [relationships, theme], + ); + + const nodeRadius = useCallback( + (node: CanvasNode): number => { + if (node.kind !== 'user') return 5; + return 6 + Math.min(6, Math.log2(1 + (degreeById.get(node.id) ?? 0)) * 1.6); + }, + [degreeById], + ); + + // Soft community halos, painted under everything else + const paintCommunities = useCallback( + (ctx: CanvasRenderingContext2D) => { + if (!communities) return; + for (const node of graphData.nodes) { + const community = communities.get(node.id); + if (community === undefined || node.x === undefined || node.y === undefined) continue; + ctx.beginPath(); + ctx.arc(node.x, node.y, nodeRadius(node) + 7, 0, 2 * Math.PI); + ctx.fillStyle = hexToRgba(communityColor(community), 0.13); + ctx.fill(); + } + }, + [communities, graphData, nodeRadius], + ); + + // Community captions at each community centroid, over the graph + const paintCaptions = useCallback( + (ctx: CanvasRenderingContext2D, globalScale: number) => { + if (!communities || communityLabels.size === 0) return; + const sums = new Map(); + for (const node of graphData.nodes) { + const community = communities.get(node.id); + if (community === undefined || !communityLabels.has(community)) continue; + if (node.x === undefined || node.y === undefined) continue; + const sum = sums.get(community) ?? { x: 0, y: 0, n: 0 }; + sum.x += node.x; + sum.y += node.y; + sum.n += 1; + sums.set(community, sum); + } + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + for (const [community, sum] of sums) { + const label = communityLabels.get(community)!; + ctx.font = `600 ${Math.max(5, 13 / globalScale)}px "Inter Tight", sans-serif`; + ctx.fillStyle = hexToRgba(communityColor(community), 0.75); + ctx.fillText(`#${label}`, sum.x / sum.n, sum.y / sum.n); + } + }, + [communities, communityLabels, graphData], + ); + + const paintNode = useCallback( + (nodeObj: NodeObject, ctx: CanvasRenderingContext2D, globalScale: number) => { + const node = nodeObj as CanvasNode; + const x = node.x ?? 0; + const y = node.y ?? 0; + const dimmed = highlightSet !== null && !highlightSet.has(node.id); + const onPath = pathIdSet?.has(node.id) ?? false; + ctx.save(); + ctx.globalAlpha = dimmed && !onPath ? DIM_ALPHA : 1; + + // Birth / focus pulse: an expanding, fading ring + const pulseStart = + node.__bornAt && Date.now() - node.__bornAt < PULSE_MS + ? node.__bornAt + : node.id === focusId && Date.now() - focusPulseAt.current < PULSE_MS + ? focusPulseAt.current + : null; + + if (node.kind === 'user') { + const r = nodeRadius(node); + const ringColor = relationshipColor(node.id); + + if (pulseStart) { + const t = (Date.now() - pulseStart) / PULSE_MS; + ctx.beginPath(); + ctx.arc(x, y, r + t * 14, 0, 2 * Math.PI); + ctx.strokeStyle = hexToRgba(node.id === focusId ? theme.halo : ringColor, 0.6 * (1 - t)); + ctx.lineWidth = 2; + ctx.stroke(); + } + + if (node.id === selectedId || node.id === focusId) { + ctx.shadowColor = node.id === selectedId ? theme.halo : ringColor; + ctx.shadowBlur = 14; + } + + // Disc: avatar when loaded, else initial on a tinted disc + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fillStyle = hexToRgba(generateRandomColor(node.pubky), 0.35); + ctx.fill(); + ctx.shadowBlur = 0; + + const img = avatarImage(node.pubky, Boolean(node.image)); + if (img) { + ctx.save(); + ctx.beginPath(); + ctx.arc(x, y, r - 0.5, 0, 2 * Math.PI); + ctx.clip(); + ctx.drawImage(img, x - r, y - r, r * 2, r * 2); + ctx.restore(); + } else { + ctx.fillStyle = theme.label; + ctx.font = `600 ${Math.max(4, r)}px "Inter Tight", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText((node.name || node.pubky).charAt(0).toUpperCase(), x, y + 0.5); + } + + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.strokeStyle = onPath ? theme.halo : ringColor; + ctx.lineWidth = node.id === focusId || onPath ? 2.4 : 1.6; + ctx.stroke(); + + // A small pin dot marks drag-pinned nodes + if (node.__pinned) { + ctx.beginPath(); + ctx.arc(x + r * 0.75, y - r * 0.75, 1.6, 0, 2 * Math.PI); + ctx.fillStyle = theme.halo; + ctx.fill(); + } + + // Name label when zoomed in, hovered, selected, or on the traced path + if (globalScale >= 1.6 || node.id === hoverId || node.id === selectedId || onPath) { + ctx.font = `500 ${Math.max(3.5, 10 / globalScale)}px "Inter Tight", sans-serif`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + ctx.fillStyle = hexToRgba(theme.label, dimmed && !onPath ? DIM_ALPHA : 0.85); + ctx.fillText(node.name || `${node.pubky.slice(0, 8)}…`, x, y + r + 1.5); + } + } else if (node.kind === 'tag') { + const color = labelColor(node.label); + const fontSize = 5; + ctx.font = `600 ${fontSize}px "Inter Tight", sans-serif`; + const textWidth = ctx.measureText(node.label).width; + const w = textWidth + 8; + const h = fontSize + 5; + if (pulseStart) { + const t = (Date.now() - pulseStart) / PULSE_MS; + ctx.globalAlpha = Math.min(1, t * 2) * (dimmed ? DIM_ALPHA : 1); + } + ctx.beginPath(); + ctx.roundRect(x - w / 2, y - h / 2, w, h, h / 2); + ctx.fillStyle = hexToRgba(color, 0.22); + ctx.fill(); + ctx.strokeStyle = color; + ctx.lineWidth = 0.8; + ctx.stroke(); + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(node.label, x, y + 0.5); + } else { + // post: small muted rounded square; content lives in the panel + const s = 4.5; + if (pulseStart) { + const t = (Date.now() - pulseStart) / PULSE_MS; + ctx.globalAlpha = Math.min(1, t * 2) * (dimmed ? DIM_ALPHA : 1); + } + ctx.beginPath(); + ctx.roundRect(x - s, y - s, s * 2, s * 2, 1.5); + ctx.fillStyle = hexToRgba(theme.post, 0.16); + ctx.fill(); + ctx.strokeStyle = node.id === selectedId ? theme.halo : hexToRgba(theme.post, 0.7); + ctx.lineWidth = 0.8; + ctx.stroke(); + } + ctx.restore(); + }, + [highlightSet, pathIdSet, hoverId, selectedId, focusId, nodeRadius, relationshipColor, theme], + ); + + const paintPointerArea = useCallback( + (nodeObj: NodeObject, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => { + const node = nodeObj as CanvasNode; + const x = node.x ?? 0; + const y = node.y ?? 0; + ctx.fillStyle = color; + const pad = coarsePointer ? 5 : 3; + // Pointer areas paint in graph units and shrink with the camera; keep a + // minimum on-screen grab radius so drags land at overview zoom too + // (capped so far-out zoom does not blanket neighbors) + const minRadius = Math.min(20, (coarsePointer ? 14 : 11) / globalScale); + if (node.kind === 'tag') { + // Same geometry as the painted pill, so long labels stay clickable + ctx.font = `600 5px "Inter Tight", sans-serif`; + const w = Math.max(ctx.measureText(node.label).width + 8 + pad * 2, minRadius * 2); + const h = Math.max(10 + pad * 2, minRadius * 2); + ctx.fillRect(x - w / 2, y - h / 2, w, h); + return; + } + const r = Math.max((node.kind === 'user' ? nodeRadius(node) : 5.5) + pad, minRadius); + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fill(); + }, + [nodeRadius, coarsePointer], + ); + + // Timestamp range of follow edges, for the recency ramp normalization + const followTimeRange = useMemo(() => { + let min = Infinity; + let max = -Infinity; + for (const edge of edges) { + if ((edge.type === 'FOLLOWS' || edge.type === 'FRIEND') && edge.indexed_at !== undefined) { + min = Math.min(min, edge.indexed_at); + max = Math.max(max, edge.indexed_at); + } + } + return min < max ? { min, max } : null; + }, [edges]); + + // Edge color encodes, in priority order: the traced path, the ego story + // (edges touching the focus keep relationship colors), community structure + // when communities are on (intra-community = tint, bridges = bright + // neutral), and recency for the remaining neighbor-to-neighbor follows + // (fresh = warm bright, old = faded gray; quadratic so only genuinely new + // connections light up). + const linkColor = useCallback( + (linkObj: LinkObject): string => { + const link = linkObj as CanvasLink; + if (isPathLink(link)) return hexToRgba(theme.halo, 0.9); + const source = endpointId(link.source); + const target = endpointId(link.target); + // An explicit edge spotlight dims by edge identity; otherwise links dim + // when either endpoint is outside the node spotlight + const dimmed = spotlightEdges + ? !spotlightEdges.has(linkKeyOf(link)) + : highlightSet !== null && !(highlightSet.has(source) && highlightSet.has(target)); + const alpha = dimmed ? 0.04 : 0.5; + switch (link.type) { + case 'FRIEND': + case 'FOLLOWS': { + if (source === focusId || target === focusId) { + if (link.type === 'FRIEND') return hexToRgba(theme.friend, dimmed ? 0.04 : 0.65); + // Arrow points at the followed side; color by the far endpoint + const far = source === focusId ? target : source; + return hexToRgba(relationshipColor(far), alpha); + } + if (communities) { + const a = communities.get(source); + const b = communities.get(target); + if (a !== undefined && b !== undefined) { + if (a === b) return hexToRgba(communityColor(a), dimmed ? 0.04 : 0.45); + // Bridges between communities are the structurally interesting + // edges; they stay bright and neutral + return dimmed ? 'rgba(245, 245, 255, 0.04)' : 'rgba(245, 245, 255, 0.6)'; + } + } + const t = followTimeRange + ? link.indexed_at !== undefined + ? (link.indexed_at - followTimeRange.min) / (followTimeRange.max - followTimeRange.min) + : 0 + : 0.35; + return edgeRecencyColor(t * t, dimmed); + } + case 'TAGGED': + return hexToRgba(labelColor(link.label ?? ''), alpha); + default: + return hexToRgba(theme.edgeMuted, dimmed ? 0.04 : 0.8); + } + }, + [highlightSet, spotlightEdges, isPathLink, focusId, relationshipColor, theme, communities, followTimeRange], + ); + + // Count chips on aggregated tag edges, drawn over the link line + const paintLink = useCallback( + (linkObj: LinkObject, ctx: CanvasRenderingContext2D, globalScale: number) => { + const link = linkObj as CanvasLink; + if (!link.labels || link.labels.length < 2) return; + const source = link.source as NodeObject; + const target = link.target as NodeObject; + if (typeof source !== 'object' || typeof target !== 'object') return; + if (source.x === undefined || target.x === undefined) return; + const dimmed = spotlightEdges + ? !spotlightEdges.has(linkKeyOf(link)) + : highlightSet !== null && !(highlightSet.has(String(source.id)) && highlightSet.has(String(target.id))); + const x = (source.x + (target.x ?? 0)) / 2; + const y = ((source.y ?? 0) + (target.y ?? 0)) / 2; + // Chips on short edges inside dense clusters would stack over the nodes + const dist = Math.hypot((target.x ?? 0) - source.x, (target.y ?? 0) - (source.y ?? 0)); + if (dist < 26 && globalScale < 2.2) return; + const color = labelColor(link.label ?? ''); + const text = String(link.labels.length); + const fontSize = 3.8; + ctx.save(); + ctx.globalAlpha = dimmed ? DIM_ALPHA : 0.9; + ctx.font = `700 ${fontSize}px "Inter Tight", sans-serif`; + const r = 3; + ctx.beginPath(); + ctx.arc(x, y, r, 0, 2 * Math.PI); + ctx.fillStyle = '#101014'; + ctx.fill(); + ctx.strokeStyle = color; + ctx.lineWidth = 0.7; + ctx.stroke(); + ctx.fillStyle = color; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText(text, x, y + 0.3); + ctx.restore(); + }, + [highlightSet, spotlightEdges], + ); + + // The visible edges are hairlines; the interactive surface is painted much + // fatter, and the count chip gets a generous disc so it works as the button + // it looks like (twice the size on touch screens) + const linkPointerAreaPaint = useCallback( + (linkObj: LinkObject, color: string, ctx: CanvasRenderingContext2D, globalScale: number) => { + const link = linkObj as CanvasLink; + const source = link.source as NodeObject; + const target = link.target as NodeObject; + if (typeof source !== 'object' || typeof target !== 'object') return; + if (source.x === undefined || target.x === undefined) return; + ctx.strokeStyle = color; + ctx.lineWidth = Math.min(10, Math.max(coarsePointer ? 9 : 5, (coarsePointer ? 16 : 10) / globalScale)); + ctx.beginPath(); + ctx.moveTo(source.x, source.y ?? 0); + ctx.lineTo(target.x, target.y ?? 0); + ctx.stroke(); + if ((link.labels?.length ?? 0) > 1) { + const x = (source.x + (target.x ?? 0)) / 2; + const y = ((source.y ?? 0) + (target.y ?? 0)) / 2; + ctx.beginPath(); + ctx.arc( + x, + y, + Math.min(14, Math.max(coarsePointer ? 8 : 5.5, (coarsePointer ? 16 : 11) / globalScale)), + 0, + 2 * Math.PI, + ); + ctx.fillStyle = color; + ctx.fill(); + } + }, + [coarsePointer], + ); + + // Actionable edges advertise themselves with a pointer cursor + const handleLinkHover = useCallback((linkObj: LinkObject | null) => { + const link = linkObj as CanvasLink | null; + hoveredLinkRef.current = link !== null; + const canvas = containerRef.current?.querySelector('canvas'); + if (canvas) canvas.style.cursor = link && link.type === 'TAGGED' ? 'pointer' : ''; + }, []); + + const screenPositionOf = useCallback((node: CanvasNode): { x: number; y: number } | null => { + const fg = graphRef.current; + if (!fg || node.x === undefined || node.y === undefined) return null; + const pos = fg.graph2ScreenCoords(node.x, node.y); + return { x: pos.x, y: pos.y }; + }, []); + + const handleNodeHover = useCallback( + (nodeObj: NodeObject | null) => { + const node = nodeObj as CanvasNode | null; + hoveredNodeRef.current = node !== null; + setHoverId(node ? String(node.id) : null); + if (!onUserHover || coarsePointer) return; + if (hoverTimer.current) clearTimeout(hoverTimer.current); + if (node && node.kind === 'user') { + hoverTimer.current = setTimeout(() => { + onUserHover(node, screenPositionOf(node)); + }, HOVER_INTENT_MS); + } else { + onUserHover(null, null); + } + }, + [onUserHover, coarsePointer, screenPositionOf], + ); + + // Stable accessors: force-graph re-materializes per-link state (including + // path particles) whenever an accessor prop changes identity, so inline + // lambdas would reset them on every hover-driven re-render. + const linkWidth = useCallback( + (link: LinkObject) => { + const l = link as CanvasLink; + if (isPathLink(l)) return 2.4; + if (l.type === 'FRIEND') return 1.8; + return 1; + }, + [isPathLink], + ); + const linkCurvature = useCallback((link: LinkObject) => ((link as CanvasLink).type === 'TAGGED' ? 0.18 : 0), []); + const linkModeAfter = useCallback(() => 'after' as const, []); + const arrowLength = useCallback((link: LinkObject) => { + const l = link as CanvasLink; + if (l.type === 'FRIEND') return 0; + // Aggregated tag edges have a canonicalized direction: no arrow + if (l.type === 'TAGGED' && (l.labels?.length ?? 0) > 1) return 0; + // Hub edges out of a tag pill read better without arrowheads + if (l.type === 'TAGGED' && endpointId(l.source).startsWith('tag:')) return 0; + return 3; + }, []); + const particleCount = useCallback((link: LinkObject) => (isPathLink(link as CanvasLink) ? 3 : 0), [isPathLink]); + const particleColor = useCallback(() => theme.halo, [theme]); + + const handleNodeClick = useCallback( + (nodeObj: NodeObject) => { + const id = String(nodeObj.id); + const now = Date.now(); + if (lastClick.current.id === id && now - lastClick.current.at < DOUBLE_CLICK_MS) { + lastClick.current = { id: '', at: 0 }; + onNodeExpand(id); + return; + } + lastClick.current = { id, at: now }; + onNodeClick(id); + }, + [onNodeClick, onNodeExpand], + ); + + // The engine's own hit canvas refreshes on an 800ms throttle, so during + // simulation ticks and camera motion it lags what the eye sees; re-setting + // the pointer painter forces the library to flush it (cheap at our scale) + const flushHitCanvas = useCallback(() => { + const fg = graphRef.current as unknown as { nodePointerAreaPaint?: (fn: unknown) => unknown } | undefined; + fg?.nodePointerAreaPaint?.(paintPointerArea); + }, [paintPointerArea]); + + // Background clicks are detected here instead of the engine: registering + // onBackgroundClick with the library arms a zero-tolerance gesture guard + // that suppresses EVERY click (nodes and chips included) after 1px of + // mouse jitter between press and release + const hoveredNodeRef = useRef(false); + const hoveredLinkRef = useRef(false); + const pressPosRef = useRef<{ x: number; y: number } | null>(null); + + // Single background click deselects (forwarded to the page); a quick second + // click zooms toward the clicked region, mirroring node double-click + const lastBackgroundClick = useRef(0); + const handleBackgroundClick = useCallback( + (event: MouseEvent) => { + const now = Date.now(); + if (now - lastBackgroundClick.current < DOUBLE_CLICK_MS) { + lastBackgroundClick.current = 0; + const fg = graphRef.current; + if (fg) { + const point = fg.screen2GraphCoords(event.offsetX, event.offsetY); + // A directed zoom consumes any pending auto-fit + didInitialFit.current = true; + fg.centerAt(point.x, point.y, 350); + fg.zoom(fg.zoom() * 1.7, 350); + } + return; + } + lastBackgroundClick.current = now; + onBackgroundClick(); + }, + [onBackgroundClick], + ); + + const handleLinkClick = useCallback( + (linkObj: LinkObject, event: MouseEvent) => { + const link = linkObj as CanvasLink; + onLinkClick?.( + { + source: endpointId(link.source), + target: endpointId(link.target), + type: link.type, + label: link.label, + labels: link.labels, + indexed_at: link.indexed_at, + }, + { x: event.offsetX, y: event.offsetY }, + ); + }, + [onLinkClick], + ); + + const markEngineReady = useCallback(() => { + setEngineReady((ready) => (ready ? ready : true)); + }, []); + + const handleEngineTick = useCallback(() => { + markEngineReady(); + flushHitCanvas(); + }, [markEngineReady, flushHitCanvas]); + + useEffect(() => { + const container = containerRef.current; + if (!container) return; + const onPointerDown = (event: PointerEvent) => { + pressPosRef.current = { x: event.clientX, y: event.clientY }; + }; + const onPointerUp = (event: PointerEvent) => { + const press = pressPosRef.current; + pressPosRef.current = null; + if (!press || event.button !== 0) return; + // Same gesture tolerance the engine grants node clicks + if (Math.hypot(event.clientX - press.x, event.clientY - press.y) > 5) return; + if (hoveredNodeRef.current || hoveredLinkRef.current) return; + lastClick.current = { id: '', at: 0 }; + const bounds = container.getBoundingClientRect(); + handleBackgroundClick({ + offsetX: event.clientX - bounds.left, + offsetY: event.clientY - bounds.top, + } as MouseEvent); + }; + container.addEventListener('pointerdown', onPointerDown); + container.addEventListener('pointerup', onPointerUp); + return () => { + container.removeEventListener('pointerdown', onPointerDown); + container.removeEventListener('pointerup', onPointerUp); + }; + }, [handleBackgroundClick]); + + useImperativeHandle( + ref, + (): SocialGraphHandle => ({ + zoomIn: () => graphRef.current?.zoom((graphRef.current?.zoom() ?? 1) * 1.4, 300), + zoomOut: () => graphRef.current?.zoom((graphRef.current?.zoom() ?? 1) / 1.4, 300), + fit: () => graphRef.current?.zoomToFit(400, 48), + screenPositionOf: (nodeId: string) => { + const node = graphData.nodes.find((n) => n.id === nodeId); + const fg = graphRef.current; + if (!node || !fg || node.x === undefined || node.y === undefined) return null; + return fg.graph2ScreenCoords(node.x, node.y); + }, + screenMidpointOf: (aId: string, bId: string) => { + const a = graphData.nodes.find((n) => n.id === aId); + const b = graphData.nodes.find((n) => n.id === bId); + const fg = graphRef.current; + if (!a || !b || !fg || a.x === undefined || b.x === undefined) return null; + return fg.graph2ScreenCoords((a.x + b.x) / 2, ((a.y ?? 0) + (b.y ?? 0)) / 2); + }, + centerOn: (nodeId: string) => { + const node = graphData.nodes.find((n) => n.id === nodeId); + const fg = graphRef.current; + if (!node || !fg || node.x === undefined || node.y === undefined) return; + // A directed fly consumes any pending auto-fit, which would otherwise + // zoom back out when the simulation settles + didInitialFit.current = true; + // Two phases: ease out a little, then glide onto the target + const current = fg.zoom(); + fg.zoom(Math.max(0.8, current * 0.85), 180); + fg.centerAt(node.x, node.y, 450); + setTimeout(() => fg.zoom(Math.max(current, 1.6), 320), 460); + }, + setPaused: (paused: boolean) => { + for (const node of graphData.nodes) { + if (paused) { + node.fx = node.x; + node.fy = node.y; + } else if (!node.__pinned) { + node.fx = undefined; + node.fy = undefined; + } + } + if (!paused) graphRef.current?.d3ReheatSimulation(); + }, + releasePins: () => { + for (const node of graphData.nodes) { + if (node.__pinned) { + node.__pinned = false; + node.fx = undefined; + node.fy = undefined; + } + } + graphRef.current?.d3ReheatSimulation(); + }, + }), + [graphData], + ); + + return ( +
+ {width > 0 && height > 0 && ( + ''} + linkColor={linkColor} + linkWidth={linkWidth} + linkCurvature={linkCurvature} + linkCanvasObjectMode={linkModeAfter} + linkCanvasObject={paintLink} + linkPointerAreaPaint={linkPointerAreaPaint} + linkDirectionalArrowLength={arrowLength} + linkDirectionalArrowRelPos={0.92} + linkDirectionalParticles={particleCount} + linkDirectionalParticleSpeed={0.006} + linkDirectionalParticleWidth={3.2} + linkDirectionalParticleColor={particleColor} + onNodeClick={handleNodeClick} + onNodeHover={handleNodeHover} + onLinkClick={handleLinkClick} + onLinkHover={handleLinkHover} + onNodeDragEnd={(nodeObj) => { + const node = nodeObj as CanvasNode; + node.fx = node.x; + node.fy = node.y; + node.__pinned = true; + }} + onZoom={flushHitCanvas} + onEngineTick={handleEngineTick} + onRenderFramePre={(ctx) => paintCommunities(ctx)} + onRenderFramePost={(ctx, globalScale) => paintCaptions(ctx, globalScale)} + onEngineStop={() => { + if (!didInitialFit.current) { + didInitialFit.current = true; + graphRef.current?.zoomToFit(400, 60); + } + }} + cooldownTicks={120} + /> + )} +
+ ); +}); diff --git a/src/components/organisms/SocialGraph/SocialGraph.types.ts b/src/components/organisms/SocialGraph/SocialGraph.types.ts new file mode 100644 index 0000000000..8f9ed98636 --- /dev/null +++ b/src/components/organisms/SocialGraph/SocialGraph.types.ts @@ -0,0 +1,48 @@ +import type { GraphRelationship, SocialGraphVisualEdge } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; + +/** Imperative camera and physics controls exposed to the page overlays. */ +export interface SocialGraphHandle { + zoomIn: () => void; + zoomOut: () => void; + fit: () => void; + /** Screen position of a node inside the canvas container, null when unknown */ + screenPositionOf: (nodeId: string) => { x: number; y: number } | null; + /** Screen position of the midpoint between two nodes (edge chips) */ + screenMidpointOf: (aId: string, bId: string) => { x: number; y: number } | null; + /** Fly the camera to a node id in two phases (no-op when unknown). */ + centerOn: (nodeId: string) => void; + /** Freeze/unfreeze the simulation without stopping rendering. */ + setPaused: (paused: boolean) => void; + /** Release every drag-pinned node back to the simulation. */ + releasePins: () => void; +} + +export interface SocialGraphProps { + nodes: NexusGraphNode[]; + edges: SocialGraphVisualEdge[]; + /** Prefixed id of the user relationships are derived against */ + focusId: string | null; + selectedId: string | null; + relationships: Map; + /** When set, everything outside this node-id set dims (legend hover, social proof, traces) */ + spotlight: Set | null; + /** When set, links outside this edge-key set dim (legend edge-row hover); see edgeKey in useSocialGraph.utils */ + spotlightEdges?: Set | null; + /** Path-ordered node ids of a traced shortest path; its edges glow and carry particles */ + pathIds: string[] | null; + /** nodeId -> community index; communities paint soft halos behind users */ + communities: Map | null; + /** community index -> caption (dominant tag label) */ + communityLabels: Map; + /** Single click / tap on a node */ + onNodeClick: (id: string) => void; + /** Double click / double tap on a node */ + onNodeExpand: (id: string) => void; + onBackgroundClick: () => void; + /** Click on an edge (used for aggregated tag-edge popovers) */ + onLinkClick?: (edge: SocialGraphVisualEdge, screen: { x: number; y: number }) => void; + /** Hover intent on a user node: node + its current screen position, or null on leave */ + onUserHover?: (node: NexusGraphNode | null, screen: { x: number; y: number } | null) => void; + className?: string; +} diff --git a/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx new file mode 100644 index 0000000000..04e1cbcf31 --- /dev/null +++ b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.test.tsx @@ -0,0 +1,97 @@ +import { render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { SocialGraphNodePanel } from './SocialGraphNodePanel'; + +vi.mock('@/hooks/useIsFollowing/useIsFollowing', () => ({ + useIsFollowing: () => ({ isFollowing: false, isLoading: false }), +})); + +vi.mock('@/hooks/useFollowUser/useFollowUser', () => ({ + useFollowUser: () => ({ toggleFollow: vi.fn(), isUserLoading: () => false }), +})); + +const mockUseTtlSubscription = vi.fn().mockReturnValue({ ref: () => {}, isVisible: true }); +vi.mock('@/hooks/useTtlSubscription/useTtlSubscription', () => ({ + useTtlSubscription: (options: unknown) => mockUseTtlSubscription(options), +})); + +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: () => ({ currentUserPubky: 'viewerpubky' }), +})); + +vi.mock('@/controllers/file/file', () => ({ + FileController: { getAvatarUrl: vi.fn(() => 'https://cdn.example/avatar') }, +})); + +vi.mock('@/molecules/PostPreviewCard/PostPreviewCard', () => ({ + PostPreviewCard: ({ postId }: { postId: string }) =>
{postId}
, +})); + +vi.mock('../DialogReply/DialogReply', () => ({ + DialogReply: () => null, +})); + +const baseProps = { + relationship: 'following' as const, + isExpanded: false, + isExpanding: false, + proofUsers: [{ pubky: 'proof1', name: 'Proof One', image: null }], + onProofHover: vi.fn(), + onExpand: vi.fn(), + onRefreshNode: vi.fn(), + onFocus: vi.fn(), + onTracePath: vi.fn(), + isTracing: false, + onClose: vi.fn(), +}; + +describe('SocialGraphNodePanel', () => { + it('renders a user card with follow, focus, and expand actions', () => { + const node: NexusGraphNode = { kind: 'user', id: 'user:abc', pubky: 'abc', name: 'Alice', image: null }; + render(); + + expect(screen.getByText('Alice')).toBeInTheDocument(); + // The shared FollowButton molecule renders for other users + expect(screen.getByLabelText('Follow')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-expand"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-focus"]')).toBeInTheDocument(); + // Social proof strip and trace-path action render for other users + expect(document.querySelector('[data-cy="graph-panel-proof"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-trace"]')).toBeInTheDocument(); + // The pinned profile subscribes to the TTL coordinator for freshness + expect(mockUseTtlSubscription).toHaveBeenCalledWith({ type: 'user', id: 'abc' }); + }); + + it('renders the real post preview with reply', () => { + const node: NexusGraphNode = { + kind: 'post', + id: 'post:abc:123', + author_id: 'abc', + post_id: '123', + content: 'Hello graph world', + post_kind: 'short', + indexed_at: 1719000000, + }; + render(); + + // The app's real post preview renders with the composite id + expect(screen.getByTestId('post-preview')).toHaveTextContent('abc:123'); + expect(document.querySelector('[data-cy="graph-panel-reply"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-panel-focus"]')).not.toBeInTheDocument(); + }); + + it('renders a tag card with the label and usage count', () => { + const node: NexusGraphNode = { kind: 'tag', id: 'tag:bitcoin', label: 'bitcoin', count: 12 }; + render(); + + expect(screen.getByText('bitcoin')).toBeInTheDocument(); + }); + + it('disables expand when the node is already expanded', () => { + const node: NexusGraphNode = { kind: 'tag', id: 'tag:x', label: 'x', count: 1 }; + render(); + + expect(document.querySelector('[data-cy="graph-panel-expand"]')).toBeDisabled(); + }); +}); diff --git a/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx new file mode 100644 index 0000000000..125215a4fe --- /dev/null +++ b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.tsx @@ -0,0 +1,237 @@ +'use client'; + +import { useState } from 'react'; +import { Loader2, MessageCircle, Route, Waypoints, X } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { APP_ROUTES, getUserProfileUrl, POST_ROUTES } from '@/app/routes'; +import { Button } from '@/atoms/Button/Button'; +import { Link } from '@/atoms/Link/Link'; +import { Tag } from '@/atoms/Tag/Tag'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { FileController } from '@/controllers/file/file'; +import { useFollowUser } from '@/hooks/useFollowUser/useFollowUser'; +import { useIsFollowing } from '@/hooks/useIsFollowing/useIsFollowing'; +import type { GraphRelationship } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { useTtlSubscription } from '@/hooks/useTtlSubscription/useTtlSubscription'; +import { cn, formatPublicKey } from '@/libs/utils/utils'; +import { AvatarGroup } from '@/molecules/AvatarGroup/AvatarGroup'; +import { FollowButton } from '@/molecules/FollowButton/FollowButton'; +import { PostPreviewCard } from '@/molecules/PostPreviewCard/PostPreviewCard'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { AvatarWithFallback } from '../AvatarWithFallback/AvatarWithFallback'; +import { DialogReply } from '../DialogReply/DialogReply'; +import type { SocialGraphNodePanelProps } from './SocialGraphNodePanel.types'; + +const RELATIONSHIP_DOT: Record = { + self: 'bg-brand', + friend: 'bg-(--chart-2)', + following: 'bg-(--chart-3)', + follower: 'bg-(--chart-1)', + extended: 'bg-muted-foreground', +}; + +/** + * SocialGraphNodePanel + * + * Kind-aware inspector for the selected graph node: user card with follow, + * social proof, trace-path and focus actions; post nodes render the app's + * real post preview with reply-in-place; tags get their summary. + */ +export function SocialGraphNodePanel({ + node, + relationship, + isExpanded, + isExpanding, + proofUsers, + onProofHover, + onExpand, + onRefreshNode, + onFocus, + onTracePath, + isTracing, + onClose, + className, +}: SocialGraphNodePanelProps) { + const t = useTranslations('graph'); + const { currentUserPubky } = useAuthStore(); + const targetPubky = node.kind === 'user' ? node.pubky : ''; + const { isFollowing } = useIsFollowing(targetPubky); + const { toggleFollow, isUserLoading } = useFollowUser(); + const [replyOpen, setReplyOpen] = useState(false); + // Keep the pinned profile fresh while it is on screen, like other user + // surfaces do (posts get the same treatment inside PostPreviewCard) + const { ref: ttlRef } = useTtlSubscription({ type: 'user', id: targetPubky }); + + const expandButton = ( + + ); + + return ( +
+
+ + {t(`legend.${node.kind}`)} + + +
+ + {node.kind === 'user' && ( + <> +
+ +
+ + {node.name || formatPublicKey({ key: node.pubky })} + + + {formatPublicKey({ key: node.pubky })} + +
+ + + {t(`legend.${relationship}`)} + +
+
+
+ + {proofUsers.length > 0 && ( + + )} + +
+ {expandButton} + +
+ +
+ {currentUserPubky && currentUserPubky !== node.pubky && ( + toggleFollow(node.pubky, isFollowing, node.name)} + /> + )} + +
+ + {currentUserPubky && currentUserPubky !== node.pubky && ( + + )} + + )} + + {node.kind === 'post' && ( + <> + +
+ {expandButton} + +
+ + { + setReplyOpen(open); + // Refresh on close: if a reply was posted it pops into the graph + if (!open) onRefreshNode(node.id); + }} + /> + + )} + + {node.kind === 'tag' && ( + <> +
+ + + {t('panel.tagUsage', { count: node.count })} + +
+
+ {expandButton} + +
+ + )} +
+ ); +} diff --git a/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts new file mode 100644 index 0000000000..5fe6bad835 --- /dev/null +++ b/src/components/organisms/SocialGraphNodePanel/SocialGraphNodePanel.types.ts @@ -0,0 +1,31 @@ +import type { GraphRelationship } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import type { Pubky } from '@/models/models.types'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; + +export type SocialProofUser = { + pubky: Pubky; + name: string; + image: string | null; +}; + +export interface SocialGraphNodePanelProps { + node: NexusGraphNode; + relationship: GraphRelationship; + /** Whether the node's neighborhood is already merged into the view */ + isExpanded: boolean; + isExpanding: boolean; + /** People the viewer follows who follow this user (from edges already on canvas) */ + proofUsers: SocialProofUser[]; + /** Spotlight the proof connections on the canvas while hovering the strip */ + onProofHover: (hovering: boolean) => void; + onExpand: (nodeId: string) => void; + /** Re-fetch a node's neighborhood (used after replying from the panel) */ + onRefreshNode: (nodeId: string) => void; + /** Re-derive relationship colors around a user node (user nodes only) */ + onFocus: (nodeId: string) => void; + /** Trace the shortest follow path from the viewer to this user */ + onTracePath: (pubky: Pubky) => void; + isTracing: boolean; + onClose: () => void; + className?: string; +} diff --git a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx index b102d5ee36..65e21ce871 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.collection.test.tsx @@ -25,6 +25,7 @@ vi.mock('@/hooks/useFeedLayoutResolution/useFeedLayoutResolution', () => ({ effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: true, isPhoneViewport: false, }), diff --git a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx index 875f756cd9..83f9fa666f 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeed/TimelineFeed.test.tsx @@ -68,6 +68,7 @@ vi.mock('@/hooks/useFeedLayoutResolution/useFeedLayoutResolution', () => ({ effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, })), @@ -207,6 +208,7 @@ const visualLayoutResolution = { effectiveLayout: 'visual' as const, isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }; @@ -216,6 +218,7 @@ const phoneColumnsLayoutResolution = { effectiveLayout: 'columns' as const, isVisualRequested: true, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: true, }; @@ -225,6 +228,7 @@ const columnsLayoutResolution = { effectiveLayout: 'columns' as const, isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }; @@ -288,6 +292,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -335,6 +340,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -351,6 +357,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'columns', isVisualRequested: true, isVisualActive: false, + isGraphActive: false, isGridActive: false, isPhoneViewport: true, }); @@ -371,6 +378,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -390,6 +398,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -407,6 +416,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); @@ -471,6 +481,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'columns', isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: true, isPhoneViewport: false, }); @@ -529,6 +540,7 @@ describe('TimelineFeed', () => { effectiveLayout: 'visual', isVisualRequested: true, isVisualActive: true, + isGraphActive: false, isGridActive: false, isPhoneViewport: false, }); diff --git a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx index eba805c08e..a7d68e376d 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.test.tsx @@ -139,6 +139,7 @@ const gridLayoutResolution: FeedLayoutResolution = { effectiveLayout: LAYOUT.COLUMNS, isVisualRequested: false, isVisualActive: false, + isGraphActive: false, isGridActive: true, isPhoneViewport: false, }; @@ -149,6 +150,7 @@ const visualGridLayoutResolution: FeedLayoutResolution = { effectiveLayout: LAYOUT.VISUAL, isVisualRequested: true, isVisualActive: true, + isGraphActive: false, }; const mockLoadMore = vi.fn(); diff --git a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx index 780609db51..974e0c7a25 100644 --- a/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx +++ b/src/components/organisms/Timeline/Feed/TimelineFeedContent/TimelineFeedContent.tsx @@ -17,6 +17,7 @@ import { PostMainLayoutProvider } from '@/organisms/PostMain/PostMainLayoutConte import { buildFeedKey } from '@/stores/feedOptimistic/feedOptimistic.types'; import { TimelineGridPosts } from '../../Posts/GridPosts/GridPosts'; import { TimelinePosts } from '../../Posts/Posts'; +import { StreamGraphPosts } from '../../Posts/StreamGraphPosts/StreamGraphPosts'; import { NewPostsSection } from '../NewPostsSection/NewPostsSection'; import type { TimelineFeedContextValue, TimelineFeedProps } from '../TimelineFeed/TimelineFeed.types'; import { TimelineFeedContext } from '../TimelineFeed/TimelineFeedContext'; @@ -117,6 +118,7 @@ function TimelineFeedContent({ const previousMutedUserIdSetRef = useRef | null>(null); const isVisualActive = layoutResolution?.isVisualActive ?? false; + const isGraphActive = layoutResolution?.isGraphActive ?? false; const isGridActive = layoutResolution?.isGridActive ?? false; const { postIds: rawPostIds, @@ -203,7 +205,7 @@ function TimelineFeedContent({ }; const showGridEndMessage = variant !== TIMELINE_FEED_VARIANT.COLLECTION && variant !== TIMELINE_FEED_VARIANT.BOOKMARKS; - const shouldRenderChildren = !isVisualActive || isGridActive; + const shouldRenderChildren = (!isVisualActive && !isGraphActive) || isGridActive; return ( @@ -231,6 +233,14 @@ function TimelineFeedContent({ emptyState={emptyState} trailingSlot={gridTrailingSlot} /> + ) : isGraphActive ? ( + ) : isVisualActive ? ( = {}; + +vi.mock('@/hooks/useStreamGraph/useStreamGraph', () => ({ + useStreamGraph: () => ({ + nodes: [{ kind: 'user', id: 'user:a', pubky: 'a', name: 'Alice', image: null }], + edges: [], + relationships: new Map([['user:a', 'extended']]), + classCounts: new Map([['extended', 1]]), + focusId: null, + selectedNode: null, + expandedIds: new Set(), + pathIds: null, + timeBounds: { min: 1, max: 2 }, + timeCap: null, + declutter: false, + hiddenClasses: new Set(), + isExpanding: false, + isTracing: false, + select: vi.fn(), + expand: vi.fn(), + refreshNode: vi.fn(), + tracePath: vi.fn(), + clearPath: vi.fn(), + toggleClass: vi.fn(), + toggleDeclutter: vi.fn(), + setTimeCap: vi.fn(), + ...baseGraph, + }), +})); + +vi.mock('@/organisms/SocialGraph/SocialGraph', () => ({ + SocialGraph: () =>
, +})); + +vi.mock('@/stores/auth/auth.store', () => ({ + useAuthStore: () => ({ currentUserPubky: null }), +})); + +const props = { + postIds: ['a:1'], + loading: false, + loadingMore: false, + hasMore: true, + loadMore: vi.fn(), +}; + +describe('StreamGraphPosts', () => { + it('renders the canvas, legend, slim controls, and merge-more', () => { + render(); + + expect(screen.getByTestId('canvas-stub')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="stream-graph"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="graph-legend"]')).toBeInTheDocument(); + expect(document.querySelector('[data-cy="stream-graph-declutter"]')).toBeInTheDocument(); + + fireEvent.click(document.querySelector('[data-cy="stream-graph-load-more"]')!); + expect(props.loadMore).toHaveBeenCalled(); + }); + + it('hides merge-more when the stream is exhausted', () => { + render(); + expect(document.querySelector('[data-cy="stream-graph-load-more"]')).not.toBeInTheDocument(); + }); +}); diff --git a/src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx b/src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx new file mode 100644 index 0000000000..a28a3f578c --- /dev/null +++ b/src/components/organisms/Timeline/Posts/StreamGraphPosts/StreamGraphPosts.tsx @@ -0,0 +1,263 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { History, Loader2, Maximize2, Plus, Sparkles, ZoomIn, ZoomOut } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { Button } from '@/atoms/Button/Button'; +import { Spinner } from '@/atoms/Spinner/Spinner'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import type { HideableClass } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { socialProof } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { useStreamGraph } from '@/hooks/useStreamGraph/useStreamGraph'; +import { cn } from '@/libs/utils/utils'; +import { GraphTimeMachine } from '@/molecules/GraphTimeMachine/GraphTimeMachine'; +import { SocialGraphLegend } from '@/molecules/SocialGraphLegend/SocialGraphLegend'; +import { SocialGraph } from '@/organisms/SocialGraph/SocialGraph'; +import type { SocialGraphHandle } from '@/organisms/SocialGraph/SocialGraph.types'; +import { SocialGraphNodePanel } from '@/organisms/SocialGraphNodePanel/SocialGraphNodePanel'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; + +export interface StreamGraphPostsProps { + postIds: string[]; + loading: boolean; + loadingMore: boolean; + hasMore: boolean; + loadMore: () => void; + className?: string; +} + +/** + * StreamGraphPosts + * + * The feed's graph layout: the current stream as a living constellation. + * Authors are avatar nodes, posts hang off them, reply/repost lineage and + * tag hubs become visible structure. Load-more merges the next page in with + * birth pulses instead of appending rows. + */ +export function StreamGraphPosts({ + postIds, + loading, + loadingMore, + hasMore, + loadMore, + className, +}: StreamGraphPostsProps) { + const t = useTranslations('graph'); + const { currentUserPubky } = useAuthStore(); + const graph = useStreamGraph(postIds); + const canvasRef = useRef(null); + const [spotlight, setSpotlight] = useState | null>(null); + const [timeMachineOn, setTimeMachineOn] = useState(false); + + const meId = currentUserPubky ? `user:${currentUserPubky}` : null; + + const proofUsers = useMemo(() => { + if (!meId || !graph.selectedNode || graph.selectedNode.kind !== 'user' || graph.selectedNode.id === meId) { + return []; + } + const ids = new Set(socialProof(meId, graph.selectedNode.id, graph.edges)); + return graph.nodes + .filter((n): n is Extract => n.kind === 'user' && ids.has(n.id)) + .map((n) => ({ pubky: n.pubky, name: n.name, image: n.image })); + }, [meId, graph.selectedNode, graph.edges, graph.nodes]); + + const spotlightClass = useCallback( + (cls: HideableClass | null) => { + if (!cls) { + setSpotlight(null); + return; + } + const members = new Set(); + for (const node of graph.nodes) { + const nodeClass = node.kind === 'user' ? (graph.relationships.get(node.id) ?? 'extended') : node.kind; + if (nodeClass === cls) members.add(node.id); + } + setSpotlight(members); + }, + [graph.nodes, graph.relationships], + ); + + // Re-fit the camera once each merged page settles, but only when the RAW + // graph grew: filter toggles and time-machine scrubs also change the visible + // count and must not yank the camera around + const prevRawCount = useRef(0); + useEffect(() => { + if (graph.rawNodeCount <= prevRawCount.current) { + prevRawCount.current = graph.rawNodeCount; + return; + } + prevRawCount.current = graph.rawNodeCount; + const timer = setTimeout(() => canvasRef.current?.fit(), 1400); + return () => clearTimeout(timer); + }, [graph.rawNodeCount]); + + const isEmpty = !loading && graph.nodes.length === 0; + + return ( +
+
+ + { + graph.select(null); + graph.clearPath(); + setSpotlight(null); + }} + /> + + {/* Slim control stack: camera (zoom/fit), then declutter and time machine */} +
+ + + +
+ + +
+ + + + {timeMachineOn && graph.timeBounds && ( + setTimeMachineOn(false)} + /> + )} + + {graph.selectedNode && ( + undefined} + onExpand={graph.expand} + onRefreshNode={graph.refreshNode} + onFocus={(id) => canvasRef.current?.centerOn(id)} + onTracePath={graph.tracePath} + isTracing={graph.isTracing} + onClose={() => graph.select(null)} + /> + )} + + {hasMore && !isEmpty && ( + + )} + + {(loading || isEmpty) && ( +
+ {loading ? ( + + ) : ( + + {t('stream.empty')} + + )} +
+ )} +
+ ); +} diff --git a/src/components/templates/Graph/Graph.tsx b/src/components/templates/Graph/Graph.tsx new file mode 100644 index 0000000000..223cc5eae8 --- /dev/null +++ b/src/components/templates/Graph/Graph.tsx @@ -0,0 +1,553 @@ +'use client'; + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'next/navigation'; +import { RotateCcw, Users } from 'lucide-react'; +import { useTranslations } from 'next-intl'; +import { APP_ROUTES } from '@/app/routes'; +import { Button } from '@/atoms/Button/Button'; +import { Link } from '@/atoms/Link/Link'; +import { Spinner } from '@/atoms/Spinner/Spinner'; +import { Tag } from '@/atoms/Tag/Tag'; +import { Typography } from '@/atoms/Typography/Typography'; +import { GLASS_PANEL_CLASS } from '@/config/theme'; +import { FileController } from '@/controllers/file/file'; +import { useIsMobile } from '@/hooks/useIsMobile/useIsMobile'; +import { useSocialGraph } from '@/hooks/useSocialGraph/useSocialGraph'; +import type { HideableClass, TrailEntry } from '@/hooks/useSocialGraph/useSocialGraph.types'; +import { edgeKey, type SocialGraphVisualEdge, socialProof } from '@/hooks/useSocialGraph/useSocialGraph.utils'; +import { cn } from '@/libs/utils/utils'; +import type { Pubky } from '@/models/models.types'; +import { CanvasAnchoredPopover } from '@/molecules/CanvasAnchoredPopover/CanvasAnchoredPopover'; +import { GraphBreadcrumbs } from '@/molecules/GraphBreadcrumbs/GraphBreadcrumbs'; +import { GraphSearch } from '@/molecules/GraphSearch/GraphSearch'; +import { GraphTimeMachine } from '@/molecules/GraphTimeMachine/GraphTimeMachine'; +import { MobileFooter } from '@/molecules/MobileFooter/MobileFooter'; +import { SocialGraphControls } from '@/molecules/SocialGraphControls/SocialGraphControls'; +import { type EdgeLegendKind, SocialGraphLegend } from '@/molecules/SocialGraphLegend/SocialGraphLegend'; +import { ProfileHoverCard } from '@/organisms/ProfileHoverCard/ProfileHoverCard'; +import { SocialGraph } from '@/organisms/SocialGraph/SocialGraph'; +import type { SocialGraphHandle } from '@/organisms/SocialGraph/SocialGraph.types'; +import { SocialGraphNodePanel } from '@/organisms/SocialGraphNodePanel/SocialGraphNodePanel'; +import type { NexusGraphNode } from '@/services/nexus/graph/graph.types'; +import { useAuthStore } from '@/stores/auth/auth.store'; +import { useGraphStore } from '@/stores/graph/graph.store'; + +type TagEdgePopover = { labels: string[]; sourceId: string; targetId: string; x: number; y: number }; +type HoverCard = { pubky: Pubky; name: string; image: string | null; nodeId: string; x: number; y: number }; + +/** + * Re-samples a canvas-space point every animation frame while active, so + * overlays spawn next to their node and track pan, zoom, and drags. Holds the + * last point through momentary null samples. + */ +function useTrackedPoint(compute: (() => { x: number; y: number } | null) | null): { x: number; y: number } | null { + const [point, setPoint] = useState<{ x: number; y: number } | null>(null); + useEffect(() => { + if (!compute) { + setPoint(null); + return; + } + let raf = 0; + const tick = () => { + const next = compute(); + if (next) { + setPoint((prev) => (prev && Math.abs(prev.x - next.x) < 0.5 && Math.abs(prev.y - next.y) < 0.5 ? prev : next)); + } + raf = requestAnimationFrame(tick); + }; + tick(); + return () => { + cancelAnimationFrame(raf); + setPoint(null); + }; + }, [compute]); + return point; +} + +/** + * Graph + * + * The graph explorer page: a full-bleed force-directed canvas of the social + * graph around a user (`?user=` deep link, else the signed-in user), + * with an interactive legend, breadcrumb trail, search-to-add, time machine, + * declutter and community lenses, and a kind-aware inspector panel. + */ +export function Graph() { + const t = useTranslations('graph'); + const tCommon = useTranslations('common'); + const searchParams = useSearchParams(); + const { currentUserPubky } = useAuthStore(); + const centerPubky = (searchParams.get('user') as Pubky | null) ?? currentUserPubky; + const graph = useSocialGraph(); + const canvasRef = useRef(null); + // The positioned container overlays anchor against; canvas screen + // coordinates are relative to it + const pageRef = useRef(null); + const [spotlight, setSpotlight] = useState | null>(null); + const [edgeSpotlight, setEdgeSpotlight] = useState | null>(null); + const [physicsPaused, setPhysicsPaused] = useState(false); + const [timeMachineOn, setTimeMachineOn] = useState(false); + const [tagPopover, setTagPopover] = useState(null); + const [hoverCard, setHoverCard] = useState(null); + const hoverCloseTimer = useRef | null>(null); + const { load, focusId } = graph; + + useEffect(() => { + if (centerPubky) load(centerPubky); + }, [centerPubky, load]); + + // A search pick focuses, expands, and flies the camera onto the node. The + // fly is delayed so the merge lands and the physics assigns coordinates + // (centerOn no-ops on nodes without a position yet). + const flyTimer = useRef | null>(null); + const flyToNode = useCallback((nodeId: string) => { + if (flyTimer.current) clearTimeout(flyTimer.current); + flyTimer.current = setTimeout(() => canvasRef.current?.centerOn(nodeId), 900); + }, []); + useEffect( + () => () => { + if (flyTimer.current) clearTimeout(flyTimer.current); + }, + [], + ); + + const { addUser, addTag, expand } = graph; + const handlePickUser = useCallback( + async (pubky: Pubky) => { + const nodeId = `user:${pubky}`; + await addUser(pubky); + // Expands nodes that were already on the canvas; freshly added centers + // arrive with their neighborhood and no-op here + await expand(nodeId); + flyToNode(nodeId); + }, + [addUser, expand, flyToNode], + ); + const handlePickTag = useCallback( + async (label: string) => { + const nodeId = `tag:${label}`; + await addTag(label); + await expand(nodeId); + flyToNode(nodeId); + }, + [addTag, expand, flyToNode], + ); + + // Picks made in the global header search while on this page + const searchTarget = useGraphStore((state) => state.searchTarget); + useEffect(() => { + if (!searchTarget) return; + if (searchTarget.kind === 'user') void handlePickUser(searchTarget.pubky as Pubky); + else void handlePickTag(searchTarget.label); + useGraphStore.getState().clearSearchTarget(); + }, [searchTarget, handlePickUser, handlePickTag]); + + const meId = currentUserPubky ? `user:${currentUserPubky}` : null; + + // "Followed by ..." strip data, straight from edges already on the canvas + const proofUsers = useMemo(() => { + if (!meId || !graph.selectedNode || graph.selectedNode.kind !== 'user' || graph.selectedNode.id === meId) { + return []; + } + const ids = new Set(socialProof(meId, graph.selectedNode.id, graph.edges)); + return graph.nodes + .filter((n): n is Extract => n.kind === 'user' && ids.has(n.id)) + .map((n) => ({ pubky: n.pubky, name: n.name, image: n.image })); + }, [meId, graph.selectedNode, graph.edges, graph.nodes]); + + const spotlightClass = useCallback( + (cls: HideableClass | null) => { + setEdgeSpotlight(null); + if (!cls) { + setSpotlight(null); + return; + } + const members = new Set(); + for (const node of graph.nodes) { + const nodeClass = node.kind === 'user' ? (graph.relationships.get(node.id) ?? 'extended') : node.kind; + if (nodeClass === cls) members.add(node.id); + } + setSpotlight(members); + }, + [graph.nodes, graph.relationships], + ); + + // Edge rows of the legend spotlight matching edges plus their endpoints + const spotlightEdgeKind = useCallback( + (kind: EdgeLegendKind | null) => { + if (!kind) { + setEdgeSpotlight(null); + setSpotlight(null); + return; + } + const follows = graph.edges.filter( + (edge) => + (edge.type === 'FOLLOWS' || edge.type === 'FRIEND') && + edge.source !== graph.focusId && + edge.target !== graph.focusId, + ); + const keys = new Set(); + const endpoints = new Set(); + if (kind === 'fresh') { + const stamped = follows.filter((edge) => edge.indexed_at !== undefined); + let min = Infinity; + let max = -Infinity; + for (const edge of stamped) { + min = Math.min(min, edge.indexed_at!); + max = Math.max(max, edge.indexed_at!); + } + if (min < max) { + for (const edge of stamped) { + // Same normalization as the canvas ramp; spotlight the bright end + if ((edge.indexed_at! - min) / (max - min) >= 0.7) { + keys.add(edgeKey(edge)); + endpoints.add(edge.source); + endpoints.add(edge.target); + } + } + } + } else if (graph.communities) { + for (const edge of follows) { + const a = graph.communities.get(edge.source); + const b = graph.communities.get(edge.target); + if (a === undefined || b === undefined) continue; + if ((kind === 'intra') === (a === b)) { + keys.add(edgeKey(edge)); + endpoints.add(edge.source); + endpoints.add(edge.target); + } + } + } + setEdgeSpotlight(keys.size > 0 ? keys : null); + setSpotlight(endpoints.size > 0 ? endpoints : null); + }, + [graph.edges, graph.focusId, graph.communities], + ); + + // Sorted event timeline for constant-rate playback + const timelineStamps = useMemo(() => { + const stamps: number[] = []; + for (const edge of graph.edges) if (edge.indexed_at !== undefined) stamps.push(edge.indexed_at); + for (const node of graph.nodes) if (node.kind === 'post' && node.indexed_at > 0) stamps.push(node.indexed_at); + return stamps.sort((a, b) => a - b); + }, [graph.edges, graph.nodes]); + + const hasTies = useMemo( + () => + graph.edges.some( + (edge) => + (edge.type === 'FOLLOWS' || edge.type === 'FRIEND') && + edge.source !== graph.focusId && + edge.target !== graph.focusId, + ), + [graph.edges, graph.focusId], + ); + + const spotlightProof = useCallback( + (hovering: boolean) => { + setEdgeSpotlight(null); + if (!hovering || !meId || !graph.selectedNode) { + setSpotlight(null); + return; + } + const set = new Set([meId, graph.selectedNode.id]); + for (const user of proofUsers) set.add(`user:${user.pubky}`); + setSpotlight(set); + }, + [meId, graph.selectedNode, proofUsers], + ); + + const handleUserHover = useCallback((node: NexusGraphNode | null, screen: { x: number; y: number } | null) => { + if (hoverCloseTimer.current) clearTimeout(hoverCloseTimer.current); + if (node && node.kind === 'user' && screen) { + setHoverCard({ + pubky: node.pubky, + name: node.name, + image: node.image, + nodeId: node.id, + x: screen.x, + y: screen.y, + }); + } else { + // Grace period so the pointer can travel from node to card + hoverCloseTimer.current = setTimeout(() => setHoverCard(null), 250); + } + }, []); + + const handleLinkClick = useCallback((edge: SocialGraphVisualEdge, screen: { x: number; y: number }) => { + // Any tag edge is inspectable; single-label edges just show one pill + const labels = edge.labels ?? (edge.type === 'TAGGED' && edge.label ? [edge.label] : null); + if (labels && labels.length > 0) { + setTagPopover({ labels, sourceId: edge.source, targetId: edge.target, x: screen.x, y: screen.y }); + } + }, []); + + const handleHop = useCallback( + (entry: TrailEntry) => { + graph.focus(entry.id); + canvasRef.current?.centerOn(entry.id); + }, + [graph], + ); + + // A search-added graph counts as content even without a signed-in center + const hasContent = graph.nodes.length > 1; + const isEmpty = !graph.isLoading && !graph.error && !hasContent; + + // Tracked anchor points: overlays follow their canvas entity per frame + const isMobile = useIsMobile(); + const hoverNodeId = hoverCard?.nodeId ?? null; + const computeHoverPoint = useCallback( + () => (hoverNodeId ? (canvasRef.current?.screenPositionOf(hoverNodeId) ?? null) : null), + [hoverNodeId], + ); + const hoverPoint = useTrackedPoint(hoverNodeId ? computeHoverPoint : null); + + const tagSourceId = tagPopover?.sourceId ?? null; + const tagTargetId = tagPopover?.targetId ?? null; + const computeTagPoint = useCallback( + () => (tagSourceId && tagTargetId ? (canvasRef.current?.screenMidpointOf(tagSourceId, tagTargetId) ?? null) : null), + [tagSourceId, tagTargetId], + ); + const tagPoint = useTrackedPoint(tagSourceId ? computeTagPoint : null); + + const selectedNodeId = graph.selectedNode?.id ?? null; + const computeSelectedPoint = useCallback( + () => (selectedNodeId ? (canvasRef.current?.screenPositionOf(selectedNodeId) ?? null) : null), + [selectedNodeId], + ); + const selectedPoint = useTrackedPoint(selectedNodeId && !isMobile ? computeSelectedPoint : null); + + const renderNodePanel = (className: string) => + graph.selectedNode ? ( + { + graph.focus(id); + canvasRef.current?.centerOn(id); + }} + onTracePath={graph.tracePath} + isTracing={graph.isTracing} + onClose={() => graph.select(null)} + /> + ) : null; + + return ( +