diff --git a/packages/app/package.json b/packages/app/package.json index 725d6ca4..4de97dbe 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -30,12 +30,14 @@ "qr-code-styling": "^1.6.0-rc.1", "react": "^18.2.0", "react-dom": "^18.2.0", + "react-force-graph-3d": "^1.24.0", "react-intersection-observer": "^9.4.1", "react-intl": "^6.4.4", "react-router-dom": "^6.5.0", "react-textarea-autosize": "^8.4.0", "react-twitter-embed": "^4.0.4", "recharts": "^2.8.0", + "three": "^0.157.0", "use-long-press": "^3.2.0", "use-sync-external-store": "^1.2.0", "uuid": "^9.0.0", @@ -85,6 +87,7 @@ "@types/node": "^20.4.1", "@types/react": "^18.0.26", "@types/react-dom": "^18.0.10", + "@types/three": "^0.157.2", "@types/uuid": "^9.0.2", "@types/webscopeio__react-textarea-autocomplete": "^4.7.2", "@types/webtorrent": "^0.109.3", diff --git a/packages/app/src/Cache/FollowListCache.ts b/packages/app/src/Cache/FollowListCache.ts new file mode 100644 index 00000000..34f60dcb --- /dev/null +++ b/packages/app/src/Cache/FollowListCache.ts @@ -0,0 +1,48 @@ +import { db } from "Db"; +import { unixNowMs } from "@snort/shared"; +import { EventKind, RequestBuilder, TaggedNostrEvent } from "@snort/system"; +import { RefreshFeedCache } from "./RefreshFeedCache"; +import { LoginSession } from "Login"; +import SocialGraph from "SocialGraph/SocialGraph"; + +export class FollowListCache extends RefreshFeedCache { + constructor() { + super("FollowListCache", db.followLists); + } + + buildSub(session: LoginSession, rb: RequestBuilder): void { + const since = this.newest(); + rb.withFilter() + .kinds([EventKind.ContactList]) + .authors(session.follows.item) + .since(since === 0 ? undefined : since); + } + + async onEvent(evs: readonly TaggedNostrEvent[]) { + await Promise.all( + evs.map(async e => { + const update = await super.update({ + ...e, + created: e.created_at, + loaded: unixNowMs(), + }); + if (update !== "no_change") { + SocialGraph.handleFollowEvent(e); + } + }), + ); + } + + key(of: TaggedNostrEvent): string { + return of.pubkey; + } + + takeSnapshot() { + return [...this.cache.values()]; + } + + override async preload() { + await super.preload(); + this.snapshot().forEach(e => SocialGraph.handleFollowEvent(e)); + } +} diff --git a/packages/app/src/Cache/index.ts b/packages/app/src/Cache/index.ts index ee9a9522..bfc0fdc3 100644 --- a/packages/app/src/Cache/index.ts +++ b/packages/app/src/Cache/index.ts @@ -7,6 +7,7 @@ import { Payments } from "./PaymentsCache"; import { GiftWrapCache } from "./GiftWrapCache"; import { NotificationsCache } from "./Notifications"; import { FollowsFeedCache } from "./FollowsFeed"; +import { FollowListCache } from "./FollowListCache"; export const SystemDb = new SnortSystemDb(); export const UserCache = new UserProfileCache(SystemDb.users); @@ -19,6 +20,7 @@ export const InteractionCache = new EventInteractionCache(); export const GiftsCache = new GiftWrapCache(); export const Notifications = new NotificationsCache(); export const FollowsFeed = new FollowsFeedCache(); +export const FollowLists = new FollowListCache(); export async function preload(follows?: Array) { const preloads = [ @@ -30,6 +32,7 @@ export async function preload(follows?: Array) { GiftsCache.preload(), Notifications.preload(), FollowsFeed.preload(), + FollowLists.preload(), ]; await Promise.all(preloads); } diff --git a/packages/app/src/Db/index.ts b/packages/app/src/Db/index.ts index 11fe611c..b6e1d38e 100644 --- a/packages/app/src/Db/index.ts +++ b/packages/app/src/Db/index.ts @@ -2,7 +2,7 @@ import Dexie, { Table } from "dexie"; import { HexKey, NostrEvent, TaggedNostrEvent, u256 } from "@snort/system"; export const NAME = "snortDB"; -export const VERSION = 15; +export const VERSION = 16; export interface SubCache { id: string; @@ -46,6 +46,7 @@ const STORES = { gifts: "++id", notifications: "++id", followsFeed: "++id, created_at, kind", + followLists: "++pubkey", }; export class SnortDB extends Dexie { @@ -56,6 +57,7 @@ export class SnortDB extends Dexie { gifts!: Table; notifications!: Table; followsFeed!: Table; + followLists!: Table; constructor() { super(NAME); diff --git a/packages/app/src/Element/Event/NoteCreator.tsx b/packages/app/src/Element/Event/NoteCreator.tsx index 4d059bb3..6e4d9ce2 100644 --- a/packages/app/src/Element/Event/NoteCreator.tsx +++ b/packages/app/src/Element/Event/NoteCreator.tsx @@ -448,7 +448,7 @@ export function NoteCreator() { className="note-creator-icon" link="" showUsername={false} - showFollowingMark={false} + showFollowDistance={false} /> {note.pollOptions === undefined && !note.replyTo && ( { {sender && ( )} diff --git a/packages/app/src/Element/Feed/TimelineFragment.tsx b/packages/app/src/Element/Feed/TimelineFragment.tsx index 175372ab..c98dbd0f 100644 --- a/packages/app/src/Element/Feed/TimelineFragment.tsx +++ b/packages/app/src/Element/Feed/TimelineFragment.tsx @@ -36,7 +36,7 @@ export function TimelineRenderer(props: TimelineRendererProps) { <>
props.showLatest(false)} ref={ref}> {props.latest.slice(0, 3).map(p => { - return ; + return ; })} props.showLatest(true)}> {props.latest.slice(0, 3).map(p => { - return ; + return ; })} { + if (scrollbarWidth !== null) { + return scrollbarWidth; + } + + const outer = document.createElement("div"); + outer.style.visibility = "hidden"; + outer.style.width = "100px"; + + document.body.appendChild(outer); + + const widthNoScroll = outer.offsetWidth; + outer.style.overflow = "scroll"; + + const inner = document.createElement("div"); + inner.style.width = "100%"; + outer.appendChild(inner); + + const widthWithScroll = inner.offsetWidth; + + outer.parentNode?.removeChild(outer); + + scrollbarWidth = widthNoScroll - widthWithScroll; + return scrollbarWidth; +}; + export default function Modal(props: ModalProps) { const handleKeyDown = (e: KeyboardEvent) => { if (e.key === "Escape" && props.onClose) { @@ -19,10 +47,13 @@ export default function Modal(props: ModalProps) { useEffect(() => { document.body.classList.add("scroll-lock"); + document.body.style.paddingRight = `${getScrollbarWidth()}px`; + document.addEventListener("keydown", handleKeyDown); return () => { document.body.classList.remove("scroll-lock"); + document.body.style.paddingRight = ""; document.removeEventListener("keydown", handleKeyDown); }; }, []); diff --git a/packages/app/src/Element/SearchBox.tsx b/packages/app/src/Element/SearchBox.tsx index bc2bc6eb..48354ad8 100644 --- a/packages/app/src/Element/SearchBox.tsx +++ b/packages/app/src/Element/SearchBox.tsx @@ -16,6 +16,7 @@ export default function SearchBox() { const { formatMessage } = useIntl(); const [search, setSearch] = useState(""); const [searching, setSearching] = useState(false); + const [isFocused, setIsFocused] = useState(false); const navigate = useNavigate(); const location = useLocation(); @@ -120,13 +121,15 @@ export default function SearchBox() { value={search} onChange={handleChange} onKeyDown={handleKeyDown} + onFocus={() => setIsFocused(true)} + onBlur={() => setTimeout(() => setIsFocused(false), 150)} /> {searching ? ( ) : ( navigate("/search")} /> )} - {search && !searching && ( + {search && !searching && isFocused && (
diff --git a/packages/app/src/Element/SendSats.tsx b/packages/app/src/Element/SendSats.tsx index 48c37c43..d0a7691f 100644 --- a/packages/app/src/Element/SendSats.tsx +++ b/packages/app/src/Element/SendSats.tsx @@ -166,7 +166,7 @@ export default function SendSats(props: SendSatsProps) { ))} diff --git a/packages/app/src/Element/User/ProfileCard.tsx b/packages/app/src/Element/User/ProfileCard.tsx index df3dc88f..d165be58 100644 --- a/packages/app/src/Element/User/ProfileCard.tsx +++ b/packages/app/src/Element/User/ProfileCard.tsx @@ -8,6 +8,7 @@ import ProfileImage from "./ProfileImage"; import { UserWebsiteLink } from "./UserWebsiteLink"; import Text from "Element/Text"; import { useEffect, useState } from "react"; +import useLogin from "../../Hooks/useLogin"; interface RectElement { getBoundingClientRect(): { @@ -35,6 +36,7 @@ export function ProfileCard({ }) { const [showProfileMenu, setShowProfileMenu] = useState(false); const [t, setT] = useState>(); + const { publicKey: myPublicKey } = useLogin(s => ({ publicKey: s.publicKey })); useEffect(() => { if (show) { @@ -60,14 +62,14 @@ export function ProfileCard({ align="end">
- +
{/**/} - + {myPublicKey !== pubkey && }
void; imageOverlay?: ReactNode; - showFollowingMark?: boolean; + showFollowDistance?: boolean; icons?: ReactNode; showProfileCard?: boolean; } @@ -45,14 +45,13 @@ export default function ProfileImage({ size, imageOverlay, onClick, - showFollowingMark = true, + showFollowDistance = true, icons, showProfileCard, }: ProfileImageProps) { const user = useUserProfile(profile ? "" : pubkey) ?? profile; const nip05 = defaultNip ? defaultNip : user?.nip05; - const { follows } = useLogin(); - const doesFollow = follows.item.includes(pubkey); + const followDistance = SocialGraph.getFollowDistance(pubkey); const [ref, hovering] = useHover(); function handleClick(e: React.MouseEvent) { @@ -63,6 +62,12 @@ export default function ProfileImage({ } function inner() { + let followDistanceColor = ""; + if (followDistance <= 1) { + followDistanceColor = "success"; + } else if (followDistance === 2 && SocialGraph.followedByFriendsCount(pubkey) >= 10) { + followDistanceColor = "text-nostr-orange"; + } return ( <>
@@ -72,12 +77,12 @@ export default function ProfileImage({ size={size} imageOverlay={imageOverlay} icons={ - (doesFollow && showFollowingMark) || icons ? ( + (followDistance <= 2 && showFollowDistance) || icons ? ( <> {icons} - {showFollowingMark && ( + {showFollowDistance && (
- +
)} diff --git a/packages/app/src/Feed/LoginFeed.ts b/packages/app/src/Feed/LoginFeed.ts index a5042255..cc7b17bc 100644 --- a/packages/app/src/Feed/LoginFeed.ts +++ b/packages/app/src/Feed/LoginFeed.ts @@ -22,7 +22,7 @@ import { import { SnortPubKey } from "Const"; import { SubscriptionEvent } from "Subscription"; import useRelaysFeedFollows from "./RelaysFeedFollows"; -import { FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache"; +import { FollowLists, FollowsFeed, GiftsCache, Notifications, UserRelays } from "Cache"; import { Nip28Chats, Nip4Chats } from "chat"; import { useRefreshFeedCache } from "Hooks/useRefreshFeedcache"; @@ -38,6 +38,7 @@ export default function useLoginFeed() { useRefreshFeedCache(Notifications, true); useRefreshFeedCache(FollowsFeed, true); useRefreshFeedCache(GiftsCache, true); + useRefreshFeedCache(FollowLists, false); useEffect(() => { system.checkSigs = login.appData.item.preferences.checkSigs; diff --git a/packages/app/src/Hooks/useImgProxy.ts b/packages/app/src/Hooks/useImgProxy.ts index 7f2316d5..0266dcb9 100644 --- a/packages/app/src/Hooks/useImgProxy.ts +++ b/packages/app/src/Hooks/useImgProxy.ts @@ -11,8 +11,14 @@ export interface ImgProxySettings { export default function useImgProxy() { const settings = useLogin(s => s.appData.item.preferences.imgProxyConfig); - const te = new TextEncoder(); + return { + proxy: (url: string, resize?: number) => proxyImg(url, settings, resize), + }; +} + +export function proxyImg(url: string, settings?: ImgProxySettings, resize?: number) { + const te = new TextEncoder(); function urlSafe(s: string) { return s.replace(/=/g, "").replace(/\+/g, "-").replace(/\//g, "_"); } @@ -25,17 +31,12 @@ export default function useImgProxy() { ); return urlSafe(base64.encode(result)); } - - return { - proxy: (url: string, resize?: number) => { - if (!settings) return url; - if (url.startsWith("data:") || url.startsWith("blob:")) return url; - const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : ""; - const urlBytes = te.encode(url); - const urlEncoded = urlSafe(base64.encode(urlBytes)); - const path = `/${opt}/${urlEncoded}`; - const sig = signUrl(path); - return `${new URL(settings.url).toString()}${sig}${path}`; - }, - }; + if (!settings) return url; + if (url.startsWith("data:") || url.startsWith("blob:")) return url; + const opt = resize ? `rs:fit:${resize}:${resize}/dpr:${window.devicePixelRatio}` : ""; + const urlBytes = te.encode(url); + const urlEncoded = urlSafe(base64.encode(urlBytes)); + const path = `/${opt}/${urlEncoded}`; + const sig = signUrl(path); + return `${new URL(settings.url).toString()}${sig}${path}`; } diff --git a/packages/app/src/Hooks/useLists.tsx b/packages/app/src/Hooks/useLists.tsx index 7d052339..05c8d9ca 100644 --- a/packages/app/src/Hooks/useLists.tsx +++ b/packages/app/src/Hooks/useLists.tsx @@ -15,9 +15,7 @@ export function useLinkList(id: string, fn: (rb: RequestBuilder) => void) { const listStore = useRequestBuilder(NoteCollection, sub); return useMemo(() => { if (listStore.data && listStore.data.length > 0) { - return listStore.data - .map(e => NostrLink.fromTags(e.tags)) - .flat(); + return listStore.data.map(e => NostrLink.fromTags(e.tags)).flat(); } return []; }, [listStore.data]); diff --git a/packages/app/src/Login/Preferences.ts b/packages/app/src/Login/Preferences.ts index fd49d534..c4c6d50d 100644 --- a/packages/app/src/Login/Preferences.ts +++ b/packages/app/src/Login/Preferences.ts @@ -50,7 +50,7 @@ export interface UserPreferences { /** * Use imgproxy to optimize images */ - imgProxyConfig: ImgProxySettings | null; + imgProxyConfig?: ImgProxySettings; /** * Default page to select on load diff --git a/packages/app/src/Pages/HashTagsPage.tsx b/packages/app/src/Pages/HashTagsPage.tsx index 27d8c263..7eee2647 100644 --- a/packages/app/src/Pages/HashTagsPage.tsx +++ b/packages/app/src/Pages/HashTagsPage.tsx @@ -65,7 +65,7 @@ export function HashTagHeader({ tag }: { tag: string }) {

#{tag}

{pubkeys.slice(0, 5).map(a => ( - + ))} {pubkeys.length > 5 && ( diff --git a/packages/app/src/Pages/NetworkGraph.tsx b/packages/app/src/Pages/NetworkGraph.tsx new file mode 100644 index 00000000..d5e40272 --- /dev/null +++ b/packages/app/src/Pages/NetworkGraph.tsx @@ -0,0 +1,264 @@ +import ForceGraph3D, { NodeObject } from "react-force-graph-3d"; +import { useContext, useEffect, useState } from "react"; +import { STR, UID } from "../SocialGraph/UniqueIds"; +import SocialGraph from "../SocialGraph/SocialGraph"; +import { MetadataCache } from "@snort/system"; +import { SnortContext } from "@snort/system-react"; +import * as THREE from "three"; +import { defaultAvatar } from "../SnortUtils"; +import { proxyImg } from "Hooks/useImgProxy"; +import { LoginStore } from "Login"; + +interface GraphNode { + id: UID; + profile?: MetadataCache; + distance: number; + val: number; + inboundCount: number; + outboundCount: number; + color?: string; + visible: boolean; + // curvature?: number; +} + +interface GraphLink { + source: UID; + target: UID; + distance: number; +} + +interface GraphMetadata { + // usersByFollowDistance?: Map>; + userCountByDistance: number[]; + nodes?: Map; +} + +interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; + meta?: GraphMetadata; +} + +enum Direction { + INBOUND, + OUTBOUND, + BOTH, +} + +const avatar = (node: NodeObject>) => { + const login = LoginStore.snapshot(); + return node.profile?.picture + ? proxyImg(node.profile?.picture, login.appData.item.preferences.imgProxyConfig) + : defaultAvatar(node.address); +}; + +const NODE_LIMIT = 500; + +interface GraphConfig { + direction: Direction; + renderLimit: number | null; + showDistance: number; +} + +const NetworkGraph = () => { + const [graphData, setGraphData] = useState(null as GraphData | null); + const [graphConfig, setGraphConfig] = useState({ + direction: Direction.OUTBOUND, + renderLimit: NODE_LIMIT, + showDistance: 2, + }); + const [open, setOpen] = useState(false); + const system = useContext(SnortContext); + // const [showDistance, setShowDistance] = useState(2); + // const [direction, setDirection] = useState(Direction.OUTBOUND); + // const [renderLimit, setRenderLimit] = useState(NODE_LIMIT); + + const updateConfig = async (changes: Partial) => { + setGraphConfig(old => { + const newConfig = Object.assign({}, old, changes); + updateGraph(newConfig).then(graph => setGraphData(graph)); + return newConfig; + }); + }; + + const toggleConnections = () => { + if (graphConfig.direction === Direction.OUTBOUND) { + updateConfig({ direction: Direction.BOTH }); + } else { + updateConfig({ direction: Direction.OUTBOUND }); + } + }; + + const updateGraph = async (newConfig?: GraphConfig) => { + const { direction, renderLimit, showDistance } = newConfig ?? graphConfig; + const nodes = new Map(); + const links: GraphLink[] = []; + const nodesVisited = new Set(); + const userCountByDistance = Array.from( + { length: 6 }, + (_, i) => SocialGraph.usersByFollowDistance.get(i)?.size || 0, + ); + + // Go through all the nodes + for (let distance = 0; distance <= showDistance; ++distance) { + const users = SocialGraph.usersByFollowDistance.get(distance); + if (!users) break; + for (const UID of users) { + if (renderLimit && nodes.size >= renderLimit) break; // Temporary hack + const inboundCount = SocialGraph.followersByUser.get(UID)?.size || 0; + const outboundCount = SocialGraph.followedByUser.get(UID)?.size || 0; + const pubkey = STR(UID); + const node = { + id: UID, + address: pubkey, + profile: system.ProfileLoader.Cache.getFromCache(pubkey), + distance, + inboundCount, + outboundCount, + visible: true, // Setting to false only hides the rendered element, does not prevent calculations + // curvature: 0.6, + // Node size is based on the follower count + val: Math.log10(inboundCount) + 1, // 1 followers -> 1, 10 followers -> 2, 100 followers -> 3, etc., + } as GraphNode; + // A visibility boost for the origin user: + if (node.distance === 0) { + node.val = 10; // they're always larger than life + node.color = "#603285"; + } + nodes.set(UID, node); + } + } + + // Add links + for (const node of nodes.values()) { + if (direction === Direction.OUTBOUND || direction === Direction.BOTH) { + for (const followedID of SocialGraph.followedByUser.get(node.id) ?? []) { + if (!nodes.has(followedID)) continue; // Skip links to nodes that we're not rendering + if (nodesVisited.has(followedID)) continue; + links.push({ + source: node.id, + target: followedID, + distance: node.distance, + }); + } + } + // TODO: Fix filtering + /* if (direction === Direction.INBOUND || direction === Direction.BOTH) { + for (const followerID of SocialGraph.followersByUser.get(node.id) ?? []) { + if (nodesVisited.has(followerID)) continue; + const follower = nodes.get(followerID); + if (!follower) continue; // Skip links to nodes that we're not rendering + links.push({ + source: followerID, + target: node.id, + distance: follower.distance, + }); + } + }*/ + nodesVisited.add(node.id); + } + + // Squash cases, where there are a lot of nodes + + const graph: GraphData = { + nodes: [...nodes.values()], + links, + meta: { + nodes, + userCountByDistance, + }, + }; + + // console.log('!!', graph); + // for (const l of links) { + // if (!nodes.has(l.source)) { + // console.log('source missing:', l.source); + // } + // if (!nodes.has(l.target)) { + // console.log('target missing:', l.target); + // } + // } + + return graph; + }; + + const refreshData = async () => { + updateGraph().then(setGraphData); + }; + + useEffect(() => { + refreshData(); + }, []); + + return ( +
+ {!open && ( + + )} + {open && graphData && ( +
+ +
+
Degrees of separation
+
+ {graphData.meta?.userCountByDistance?.map((value, i) => { + if (i === 0 || value <= 0) return null; + const isSelected = graphConfig.showDistance === i; + return ( + + ); + })} +
+
+ >) => { + const imgTexture = new THREE.TextureLoader().load(avatar(node)); + imgTexture.colorSpace = THREE.SRGBColorSpace; + const material = new THREE.SpriteMaterial({ map: imgTexture }); + const sprite = new THREE.Sprite(material); + sprite.scale.set(12, 12, 1); + + return sprite; + }} + nodeLabel={node => `${node.profile?.name || node.address}`} + nodeAutoColorBy="distance" + linkAutoColorBy="distance" + linkDirectionalParticles={1} + nodeVisibility="visible" + numDimensions={3} + linkDirectionalArrowLength={0} + nodeOpacity={0.9} + /> +
+ +
+
+ Render limit: {graphConfig.renderLimit} nodes +
+
+ )} +
+ ); +}; + +export default NetworkGraph; diff --git a/packages/app/src/Pages/Profile/ProfileTab.tsx b/packages/app/src/Pages/Profile/ProfileTab.tsx index f909f486..5d51fd3b 100644 --- a/packages/app/src/Pages/Profile/ProfileTab.tsx +++ b/packages/app/src/Pages/Profile/ProfileTab.tsx @@ -62,13 +62,7 @@ export function RelaysTab({ id }: { id: HexKey }) { export function BookMarksTab({ id }: { id: HexKey }) { const bookmarks = useCategorizedBookmarks(id, "bookmark"); const reactions = useReactions(`bookmark:reactions:{id}`, bookmarks.map(NostrLink.fromEvent)); - return ( - - ); + return ; } const ProfileTab = { diff --git a/packages/app/src/Pages/settings/Preferences.tsx b/packages/app/src/Pages/settings/Preferences.tsx index 4fe243f9..d9a39aed 100644 --- a/packages/app/src/Pages/settings/Preferences.tsx +++ b/packages/app/src/Pages/settings/Preferences.tsx @@ -299,7 +299,7 @@ const PreferencesPage = () => { onChange={e => updatePreferences(id, { ...perf, - imgProxyConfig: e.target.checked ? DefaultImgProxy : null, + imgProxyConfig: e.target.checked ? DefaultImgProxy : undefined, }) } /> diff --git a/packages/app/src/Pages/settings/Root.tsx b/packages/app/src/Pages/settings/Root.tsx index dca0ab70..578efa6a 100644 --- a/packages/app/src/Pages/settings/Root.tsx +++ b/packages/app/src/Pages/settings/Root.tsx @@ -104,6 +104,11 @@ const SettingsIndex = () => {
+
navigate("/graph")}> + + + +
diff --git a/packages/app/src/Pages/settings/messages.ts b/packages/app/src/Pages/settings/messages.ts index 47c591f3..954d8fe8 100644 --- a/packages/app/src/Pages/settings/messages.ts +++ b/packages/app/src/Pages/settings/messages.ts @@ -60,4 +60,5 @@ export default defineMessages({ Nip05: { defaultMessage: "NIP-05" }, ReactionEmoji: { defaultMessage: "Reaction emoji" }, ReactionEmojiHelp: { defaultMessage: "Emoji to send when reactiong to a note" }, + SocialGraph: { defaultMessage: "Social Graph" }, }); diff --git a/packages/app/src/SocialGraph/SocialGraph.ts b/packages/app/src/SocialGraph/SocialGraph.ts new file mode 100644 index 00000000..13051901 --- /dev/null +++ b/packages/app/src/SocialGraph/SocialGraph.ts @@ -0,0 +1,234 @@ +import { ID, STR, UID } from "./UniqueIds"; +import { LoginStore } from "../Login"; +import { unwrap } from "../SnortUtils"; +import { HexKey, MetadataCache, NostrEvent } from "@snort/system"; + +type Unsubscribe = () => void; + +const Key = { + pubKey: null as HexKey | null, + getPubKey: () => { + return unwrap(LoginStore.snapshot().publicKey); + }, + isMine: (user: HexKey) => user === Key.getPubKey(), +}; + +export default { + followDistanceByUser: new Map(), + usersByFollowDistance: new Map>(), + profiles: new Map(), // JSON.parsed event.content of profile events + followedByUser: new Map>(), + followersByUser: new Map>(), + latestFollowEventTimestamps: new Map(), + + handleFollowEvent: function (event: NostrEvent) { + try { + const author = ID(event.pubkey); + const timestamp = event.created_at; + const existingTimestamp = this.latestFollowEventTimestamps.get(author); + if (existingTimestamp && timestamp <= existingTimestamp) { + return; + } + this.latestFollowEventTimestamps.set(author, timestamp); + + // Collect all users followed in the new event. + const followedInEvent = new Set(); + for (const tag of event.tags) { + if (tag[0] === "p") { + const followedUser = ID(tag[1]); + if (followedUser !== author) { + followedInEvent.add(followedUser); + } + } + } + + // Get the set of users currently followed by the author. + const currentlyFollowed = this.followedByUser.get(author) || new Set(); + + // Find users that need to be removed. + for (const user of currentlyFollowed) { + if (!followedInEvent.has(user)) { + this.removeFollower(user, author); + } + } + + // Add or update the followers based on the new event. + for (const user of followedInEvent) { + this.addFollower(user, author); + } + } catch (e) { + // might not be logged in or sth + } + }, + + isFollowing: function (follower: HexKey, followedUser: HexKey): boolean { + const followedUserId = ID(followedUser); + const followerId = ID(follower); + return !!this.followedByUser.get(followerId)?.has(followedUserId); + }, + + getFollowDistance: function (user: HexKey): number { + try { + if (Key.isMine(user)) { + return 0; + } + const userId = ID(user); + const distance = this.followDistanceByUser.get(userId); + return distance === undefined ? 1000 : distance; + } catch (e) { + // might not be logged in or sth + return 1000; + } + }, + + addUserByFollowDistance(distance: number, user: UID) { + if (!this.usersByFollowDistance.has(distance)) { + this.usersByFollowDistance.set(distance, new Set()); + } + if (distance <= 2) { + /* + let unsub; + // get also profile events for profile search indexing + // eslint-disable-next-line prefer-const + unsub = PubSub.subscribe({ authors: [STR(user)], kinds: [0] }, () => unsub?.(), true); + // TODO subscribe once param? + */ + } + this.usersByFollowDistance.get(distance)?.add(user); + // remove from higher distances + for (const d of this.usersByFollowDistance.keys()) { + if (d > distance) { + this.usersByFollowDistance.get(d)?.delete(user); + } + } + }, + + ensureRootUser: function () { + const myId = ID(Key.getPubKey()); + if (myId && !this.followDistanceByUser.has(myId)) { + this.followDistanceByUser.set(myId, 0); + this.addUserByFollowDistance(0, myId); + } + }, + + addFollower: function (followedUser: UID, follower: UID) { + if (typeof followedUser !== "number" || typeof follower !== "number") { + throw new Error("Invalid user id"); + } + this.ensureRootUser(); + if (!this.followersByUser.has(followedUser)) { + this.followersByUser.set(followedUser, new Set()); + } + this.followersByUser.get(followedUser)?.add(follower); + + if (!this.followedByUser.has(follower)) { + this.followedByUser.set(follower, new Set()); + } + const myId = ID(Key.getPubKey()); + + if (followedUser !== myId) { + let newFollowDistance; + if (follower === myId) { + // basically same as the next "else" block, but faster + newFollowDistance = 1; + this.addUserByFollowDistance(newFollowDistance, followedUser); + this.followDistanceByUser.set(followedUser, newFollowDistance); + } else { + const existingFollowDistance = this.followDistanceByUser.get(followedUser); + const followerDistance = this.followDistanceByUser.get(follower); + newFollowDistance = followerDistance && followerDistance + 1; + if (existingFollowDistance === undefined || (newFollowDistance && newFollowDistance < existingFollowDistance)) { + this.followDistanceByUser.set(followedUser, newFollowDistance!); + this.addUserByFollowDistance(newFollowDistance!, followedUser); + } + } + } + + this.followedByUser.get(follower)?.add(followedUser); + if (this.followedByUser.get(myId)?.has(follower)) { + /* + setTimeout(() => { + PubSub.subscribe({ authors: [STR(followedUser)], kinds: [0, 3] }, undefined, true); + }, 0); + */ + } + }, + removeFollower: function (unfollowedUser: UID, follower: UID) { + this.followersByUser.get(unfollowedUser)?.delete(follower); + this.followedByUser.get(follower)?.delete(unfollowedUser); + + if (unfollowedUser === ID(Key.getPubKey())) { + return; + } + + // iterate over remaining followers and set the smallest follow distance + let smallest = Infinity; + for (const follower of this.followersByUser.get(unfollowedUser) || []) { + const followerDistance = this.followDistanceByUser.get(follower); + if (followerDistance !== undefined && followerDistance + 1 < smallest) { + smallest = followerDistance + 1; + } + } + + if (smallest === Infinity) { + this.followDistanceByUser.delete(unfollowedUser); + } else { + this.followDistanceByUser.set(unfollowedUser, smallest); + } + }, + // TODO subscription methods for followersByUser and followedByUser. and maybe messagesByTime. and replies + followerCount: function (address: HexKey) { + const id = ID(address); + return this.followersByUser.get(id)?.size ?? 0; + }, + followedByFriendsCount: function (address: HexKey) { + let count = 0; + const myId = ID(Key.getPubKey()); + const id = ID(address); + for (const follower of this.followersByUser.get(id) ?? []) { + if (this.followedByUser.get(myId)?.has(follower)) { + count++; // should we stop at 10? + } + } + return count; + }, + getFollowedByUser: function ( + user: HexKey, + cb?: (followedUsers: Set) => void, + includeSelf = false, + ): Unsubscribe { + const userId = ID(user); + const callback = () => { + if (cb) { + const set = new Set(); + for (const id of this.followedByUser.get(userId) || []) { + set.add(STR(id)); + } + if (includeSelf) { + set.add(user); + } + cb(set); + } + }; + if (this.followedByUser.has(userId) || includeSelf) { + callback(); + } + //return PubSub.subscribe({ kinds: [3], authors: [user] }, callback); + return () => {}; + }, + getFollowersByUser: function (address: HexKey, cb?: (followers: Set) => void): Unsubscribe { + const userId = ID(address); + const callback = () => { + if (cb) { + const set = new Set(); + for (const id of this.followersByUser.get(userId) || []) { + set.add(STR(id)); + } + cb(set); + } + }; + this.followersByUser.has(userId) && callback(); + //return PubSub.subscribe({ kinds: [3], '#p': [address] }, callback); // TODO this doesn't fire when a user is unfollowed + return () => {}; + }, +}; diff --git a/packages/app/src/SocialGraph/UniqueIds.ts b/packages/app/src/SocialGraph/UniqueIds.ts new file mode 100644 index 00000000..944399c9 --- /dev/null +++ b/packages/app/src/SocialGraph/UniqueIds.ts @@ -0,0 +1,38 @@ +// should this be a class instead? convert all strings to internal representation, enable comparison +export type UID = number; + +// save space by mapping strs to internal unique ids +export class UniqueIds { + static strToUniqueId = new Map(); + static uniqueIdToStr = new Map(); + static currentUniqueId = 0; + + static id(str: string): UID { + if (str.startsWith("npub")) { + throw new Error("use hex instead of npub " + str); + } + const existing = UniqueIds.strToUniqueId.get(str); + if (existing) { + return existing; + } + const newId = UniqueIds.currentUniqueId++; + UniqueIds.strToUniqueId.set(str, newId); + UniqueIds.uniqueIdToStr.set(newId, str); + return newId; + } + + static str(id: UID): string { + const pub = UniqueIds.uniqueIdToStr.get(id); + if (!pub) { + throw new Error("pub: invalid id " + id); + } + return pub; + } + + static has(str: string): boolean { + return UniqueIds.strToUniqueId.has(str); + } +} + +export const STR = UniqueIds.str; +export const ID = UniqueIds.id; diff --git a/packages/app/src/index.tsx b/packages/app/src/index.tsx index ba49ffe5..2f49c9f9 100644 --- a/packages/app/src/index.tsx +++ b/packages/app/src/index.tsx @@ -23,6 +23,10 @@ import { import { SnortContext } from "@snort/system-react"; import { removeUndefined, throwIfOffline } from "@snort/shared"; +import React, { lazy, Suspense } from "react"; + +const NetworkGraph = lazy(() => import("Pages/NetworkGraph")); + import * as serviceWorkerRegistration from "serviceWorkerRegistration"; import { IntlProvider } from "IntlProvider"; import { getCountry, unwrap } from "SnortUtils"; @@ -224,6 +228,10 @@ const mainRoutes = [ path: "/about", element: , }, + { + path: "/graph", + element: , + }, ...OnboardingRoutes, ...WalletRoutes, ] as Array; @@ -282,7 +290,9 @@ root.render( - + Loading...
}> + + , diff --git a/packages/app/src/lang.json b/packages/app/src/lang.json index 347e18be..8edf7ed3 100644 --- a/packages/app/src/lang.json +++ b/packages/app/src/lang.json @@ -368,6 +368,9 @@ "Cu/K85": { "defaultMessage": "Translated from {lang}" }, + "CzHZoc": { + "defaultMessage": "Social Graph" + }, "D+KzKd": { "defaultMessage": "Automatically zap every note when loaded" }, diff --git a/packages/app/src/translations/en.json b/packages/app/src/translations/en.json index ef04ccaf..7a95a6f4 100644 --- a/packages/app/src/translations/en.json +++ b/packages/app/src/translations/en.json @@ -121,6 +121,7 @@ "CmZ9ls": "{n} Muted", "CsCUYo": "{n} sats", "Cu/K85": "Translated from {lang}", + "CzHZoc": "Social Graph", "D+KzKd": "Automatically zap every note when loaded", "D3idYv": "Settings", "DBiVK1": "Cache", diff --git a/yarn.lock b/yarn.lock index 3303c8a9..2fa5fb65 100644 --- a/yarn.lock +++ b/yarn.lock @@ -5,6 +5,19 @@ __metadata: version: 6 cacheKey: 8 +"3d-force-graph@npm:^1.73": + version: 1.73.0 + resolution: "3d-force-graph@npm:1.73.0" + dependencies: + accessor-fn: 1 + kapsule: 1 + three: ">=0.118 <1" + three-forcegraph: 1 + three-render-objects: ^1.29 + checksum: be9057ced895131b8788f2f20f6fc587d027e697a353bb69df2ddd6a64e91670202b13e9ea44b794491a562f20551c779d18f85c35b6f8604dbfc27a9a3f77bc + languageName: node + linkType: hard + "@aashutoshrathi/word-wrap@npm:^1.2.3": version: 1.2.6 resolution: "@aashutoshrathi/word-wrap@npm:1.2.6" @@ -1401,6 +1414,15 @@ __metadata: languageName: node linkType: hard +"@babel/runtime@npm:^7.17.8": + version: 7.23.2 + resolution: "@babel/runtime@npm:7.23.2" + dependencies: + regenerator-runtime: ^0.14.0 + checksum: 6c4df4839ec75ca10175f636d6362f91df8a3137f86b38f6cd3a4c90668a0fe8e9281d320958f4fbd43b394988958585a17c3aab2a4ea6bf7316b22916a371fb + languageName: node + linkType: hard + "@babel/template@npm:^7.22.5, @babel/template@npm:^7.3.3": version: 7.22.5 resolution: "@babel/template@npm:7.22.5" @@ -3104,6 +3126,7 @@ __metadata: "@types/node": ^20.4.1 "@types/react": ^18.0.26 "@types/react-dom": ^18.0.10 + "@types/three": ^0.157.2 "@types/use-sync-external-store": ^0.0.4 "@types/uuid": ^9.0.2 "@types/webscopeio__react-textarea-autocomplete": ^4.7.2 @@ -3144,6 +3167,7 @@ __metadata: qr-code-styling: ^1.6.0-rc.1 react: ^18.2.0 react-dom: ^18.2.0 + react-force-graph-3d: ^1.24.0 react-intersection-observer: ^9.4.1 react-intl: ^6.4.4 react-router-dom: ^6.5.0 @@ -3153,6 +3177,7 @@ __metadata: source-map-loader: ^4.0.1 tailwindcss: ^3.3.3 terser-webpack-plugin: ^5.3.9 + three: ^0.157.0 tinybench: ^2.5.1 ts-jest: ^29.1.1 ts-loader: ^9.4.4 @@ -3501,6 +3526,13 @@ __metadata: languageName: node linkType: hard +"@tweenjs/tween.js@npm:18 - 21": + version: 21.0.0 + resolution: "@tweenjs/tween.js@npm:21.0.0" + checksum: 3c2498e9916a599a54d9f8e2bd2dfe0ceb249e3453d29c7b4287310f0fbe12f37d82e986263b411a672d50544dafdda7827b1707a7250023a6440351a741dddc + languageName: node + linkType: hard + "@types/babel__core@npm:^7.1.14": version: 7.20.1 resolution: "@types/babel__core@npm:7.20.1" @@ -4052,6 +4084,25 @@ __metadata: languageName: node linkType: hard +"@types/stats.js@npm:*": + version: 0.17.2 + resolution: "@types/stats.js@npm:0.17.2" + checksum: cbe300f1a548051c353f5a17a9bde406f468cb171fad1b6ec3d50b8af539389ecbd4a99765bd67c9f7acd7608ed8c5a275b19a60c1545341c3cac58abf0f487e + languageName: node + linkType: hard + +"@types/three@npm:^0.157.2": + version: 0.157.2 + resolution: "@types/three@npm:0.157.2" + dependencies: + "@types/stats.js": "*" + "@types/webxr": "*" + fflate: ~0.6.10 + meshoptimizer: ~0.18.1 + checksum: c839492a2c09f73877fc53ffc5d1860762921bee608fdb742a5be59077d4300685346ac9ea7ac3070e41900a0a9c96dc74a452c4ad7f1eac1dcdc935abb09444 + languageName: node + linkType: hard + "@types/tough-cookie@npm:*": version: 4.0.2 resolution: "@types/tough-cookie@npm:4.0.2" @@ -4111,6 +4162,13 @@ __metadata: languageName: node linkType: hard +"@types/webxr@npm:*": + version: 0.5.6 + resolution: "@types/webxr@npm:0.5.6" + checksum: fbf79f471382a8649f04d9109a6d67a4b2c691bed3d4a9940f850cf288ab335230a67919425c412594eca80b8a69f4b40916375586c1e0a4947455398d57dd9d + languageName: node + linkType: hard + "@types/ws@npm:^8.5.5": version: 8.5.5 resolution: "@types/ws@npm:8.5.5" @@ -4546,6 +4604,13 @@ __metadata: languageName: node linkType: hard +"accessor-fn@npm:1": + version: 1.5.0 + resolution: "accessor-fn@npm:1.5.0" + checksum: b24398a79c1f2f6808cc7116fa2033c52bd5c5d49ac552dbb7f5a41618c515d7b04e6ebf3a06342d54df101c175b8e5583b1b900e768f93492ca9ed2a87b386e + languageName: node + linkType: hard + "acorn-globals@npm:^7.0.0": version: 7.0.1 resolution: "acorn-globals@npm:7.0.1" @@ -6320,7 +6385,7 @@ __metadata: languageName: node linkType: hard -"d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": +"d3-array@npm:1 - 3, d3-array@npm:2 - 3, d3-array@npm:2.10.0 - 3, d3-array@npm:^3.1.6": version: 3.2.4 resolution: "d3-array@npm:3.2.4" dependencies: @@ -6329,6 +6394,13 @@ __metadata: languageName: node linkType: hard +"d3-binarytree@npm:1": + version: 1.0.2 + resolution: "d3-binarytree@npm:1.0.2" + checksum: 6768077e82844373d78fcf5202a098217f8e534ec132231dd6aefbe49f5f04ffb1a6acb36c032e03426495a5018d291d61e0a6edf987dfb628ac447d88cd72c9 + languageName: node + linkType: hard + "d3-color@npm:1 - 3": version: 3.1.0 resolution: "d3-color@npm:3.1.0" @@ -6336,6 +6408,13 @@ __metadata: languageName: node linkType: hard +"d3-dispatch@npm:1 - 3": + version: 3.0.1 + resolution: "d3-dispatch@npm:3.0.1" + checksum: fdfd4a230f46463e28e5b22a45dd76d03be9345b605e1b5dc7d18bd7ebf504e6c00ae123fd6d03e23d9e2711e01f0e14ea89cd0632545b9f0c00b924ba4be223 + languageName: node + linkType: hard + "d3-ease@npm:^3.0.1": version: 3.0.1 resolution: "d3-ease@npm:3.0.1" @@ -6343,6 +6422,19 @@ __metadata: languageName: node linkType: hard +"d3-force-3d@npm:2 - 3": + version: 3.0.5 + resolution: "d3-force-3d@npm:3.0.5" + dependencies: + d3-binarytree: 1 + d3-dispatch: 1 - 3 + d3-octree: 1 + d3-quadtree: 1 - 3 + d3-timer: 1 - 3 + checksum: 36426a752187236c1f8b901f2244f49e19955c6f281936f2d1508779081ef856b867510a1e0ecf9a62ba5f6e17caed21a20ed4fedbbdc1887e745261723569a4 + languageName: node + linkType: hard + "d3-format@npm:1 - 3": version: 3.1.0 resolution: "d3-format@npm:3.1.0" @@ -6350,7 +6442,7 @@ __metadata: languageName: node linkType: hard -"d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": +"d3-interpolate@npm:1 - 3, d3-interpolate@npm:1.2.0 - 3, d3-interpolate@npm:^3.0.1": version: 3.0.1 resolution: "d3-interpolate@npm:3.0.1" dependencies: @@ -6359,6 +6451,13 @@ __metadata: languageName: node linkType: hard +"d3-octree@npm:1": + version: 1.0.2 + resolution: "d3-octree@npm:1.0.2" + checksum: 73161dda8f15c58db58ea1abc0a5fbffe5628f29bb3358933c696c378cee3de70226108fe0923963bbbc6a02dfee79be1e8772e86a9e15bc4e0dbc9f739b31bb + languageName: node + linkType: hard + "d3-path@npm:^3.1.0": version: 3.1.0 resolution: "d3-path@npm:3.1.0" @@ -6366,7 +6465,24 @@ __metadata: languageName: node linkType: hard -"d3-scale@npm:^4.0.2": +"d3-quadtree@npm:1 - 3": + version: 3.0.1 + resolution: "d3-quadtree@npm:3.0.1" + checksum: 5469d462763811475f34a7294d984f3eb100515b0585ca5b249656f6b1a6e99b20056a2d2e463cc9944b888896d2b1d07859c50f9c0cf23438df9cd2e3146066 + languageName: node + linkType: hard + +"d3-scale-chromatic@npm:1 - 3": + version: 3.0.0 + resolution: "d3-scale-chromatic@npm:3.0.0" + dependencies: + d3-color: 1 - 3 + d3-interpolate: 1 - 3 + checksum: a8ce4cb0267a17b28ebbb929f5e3071d985908a9c13b6fcaa2a198e1e018f275804d691c5794b970df0049725b7944f32297b31603d235af6414004f0c7f82c0 + languageName: node + linkType: hard + +"d3-scale@npm:1 - 4, d3-scale@npm:^4.0.2": version: 4.0.2 resolution: "d3-scale@npm:4.0.2" dependencies: @@ -6406,7 +6522,7 @@ __metadata: languageName: node linkType: hard -"d3-timer@npm:^3.0.1": +"d3-timer@npm:1 - 3, d3-timer@npm:^3.0.1": version: 3.0.1 resolution: "d3-timer@npm:3.0.1" checksum: 1cfddf86d7bca22f73f2c427f52dfa35c49f50d64e187eb788dcad6e927625c636aa18ae4edd44d084eb9d1f81d8ca4ec305dae7f733c15846a824575b789d73 @@ -6420,6 +6536,15 @@ __metadata: languageName: node linkType: hard +"data-joint@npm:1": + version: 1.3.1 + resolution: "data-joint@npm:1.3.1" + dependencies: + index-array-by: ^1.4.0 + checksum: 9becfaccd59ca02a4caffe6b8b9b52d4e9df8ed93dc01cd3109ef67dc406e8fc678b539f8fd62662ce9b7a1061ec3b4865ca3b4d0edf69656c0cf97f9a2c1b1a + languageName: node + linkType: hard + "data-urls@npm:^3.0.2": version: 3.0.2 resolution: "data-urls@npm:3.0.2" @@ -7455,6 +7580,13 @@ __metadata: languageName: node linkType: hard +"fflate@npm:~0.6.10": + version: 0.6.10 + resolution: "fflate@npm:0.6.10" + checksum: 96384bc4090987fe565c0de8204e3830f538144ec950576fea50aee1b42adbe9fc3ed5e7905dfa7979faaa20979def330dbebce548f3dcafc3e118cc9838526d + languageName: node + linkType: hard + "figures@npm:^3.0.0": version: 3.2.0 resolution: "figures@npm:3.2.0" @@ -7644,6 +7776,13 @@ __metadata: languageName: node linkType: hard +"fromentries@npm:^1.3.2": + version: 1.3.2 + resolution: "fromentries@npm:1.3.2" + checksum: 33729c529ce19f5494f846f0dd4945078f4e37f4e8955f4ae8cc7385c218f600e9d93a7d225d17636c20d1889106fd87061f911550861b7072f53bf891e6b341 + languageName: node + linkType: hard + "fs-extra@npm:^9.0.1": version: 9.1.0 resolution: "fs-extra@npm:9.1.0" @@ -8434,6 +8573,13 @@ __metadata: languageName: node linkType: hard +"index-array-by@npm:^1.4.0": + version: 1.4.1 + resolution: "index-array-by@npm:1.4.1" + checksum: 030ca9ddbb50b6b525ef6c4da026968df75e728915ed574a28c4437dd4b8154217b21f519136abc8c198796d9ee609ee7279c02e6f6db72d248422e460656c09 + languageName: node + linkType: hard + "infer-owner@npm:^1.0.4": version: 1.0.4 resolution: "infer-owner@npm:1.0.4" @@ -9011,6 +9157,13 @@ __metadata: languageName: node linkType: hard +"jerrypick@npm:^1.1.1": + version: 1.1.1 + resolution: "jerrypick@npm:1.1.1" + checksum: 8c247a4a7b5a6ece961cbc1ec0a0c76ca8009f5873a0fe26c4c367e4f3cf1cd7aa994c50442338b6d5d4394b1ba725f0acbe88293e0f9e11659dea86260139bf + languageName: node + linkType: hard + "jest-changed-files@npm:^29.6.3": version: 29.6.3 resolution: "jest-changed-files@npm:29.6.3" @@ -9712,6 +9865,15 @@ __metadata: languageName: node linkType: hard +"kapsule@npm:1": + version: 1.14.5 + resolution: "kapsule@npm:1.14.5" + dependencies: + lodash-es: 4 + checksum: 1208d7a172aa1b46ac35bb993be808b3cc8f5bc52967a6593ac34582a08ef83cedd669ca95a1ce2d1e29053234b20b4d98ba547288866244bdd9a128df7384e8 + languageName: node + linkType: hard + "keyv@npm:^4.5.3": version: 4.5.3 resolution: "keyv@npm:4.5.3" @@ -9849,6 +10011,13 @@ __metadata: languageName: node linkType: hard +"lodash-es@npm:4": + version: 4.17.21 + resolution: "lodash-es@npm:4.17.21" + checksum: 05cbffad6e2adbb331a4e16fbd826e7faee403a1a04873b82b42c0f22090f280839f85b95393f487c1303c8a3d2a010048bf06151a6cbe03eee4d388fb0a12d2 + languageName: node + linkType: hard + "lodash.debounce@npm:^4.0.8": version: 4.0.8 resolution: "lodash.debounce@npm:4.0.8" @@ -10181,6 +10350,13 @@ __metadata: languageName: node linkType: hard +"meshoptimizer@npm:~0.18.1": + version: 0.18.1 + resolution: "meshoptimizer@npm:0.18.1" + checksum: 101dbed8abd4cf167cdb7a0bc13db90dd0743332c689e43b18cc5254d238f0766750752432401fa63dc7e9e32399ef68daacf48f0d89db1484042c1761c7362d + languageName: node + linkType: hard + "methods@npm:~1.1.2": version: 1.1.2 resolution: "methods@npm:1.1.2" @@ -10536,6 +10712,47 @@ __metadata: languageName: node linkType: hard +"ngraph.events@npm:^1.0.0, ngraph.events@npm:^1.2.1": + version: 1.2.2 + resolution: "ngraph.events@npm:1.2.2" + checksum: 358d312ebdf8eeddfac9741938f38cb7fe488b402641900613f632fe81fe63da92543db050c659e5a7dc7c28750b1109ce4d78cc9d68e66d732fba7128efbbc2 + languageName: node + linkType: hard + +"ngraph.forcelayout@npm:3": + version: 3.3.1 + resolution: "ngraph.forcelayout@npm:3.3.1" + dependencies: + ngraph.events: ^1.0.0 + ngraph.merge: ^1.0.0 + ngraph.random: ^1.0.0 + checksum: 580ab50debd7cf03bae7752337496f45158a8808a6494df71080a28c5f047fa146d53daf5635ed22bb06cc95b06b217e3c104fe2071d8e5beb036c7697871b7d + languageName: node + linkType: hard + +"ngraph.graph@npm:20": + version: 20.0.1 + resolution: "ngraph.graph@npm:20.0.1" + dependencies: + ngraph.events: ^1.2.1 + checksum: 16e42c4c4fdabf8243b42291c8c5bf5baa0614773d62edf911b86ef2aa9f60a38e107a9d1bb97ab6feac2ac514c8e04a0aa24d1992502b21420fdd5f563c96cb + languageName: node + linkType: hard + +"ngraph.merge@npm:^1.0.0": + version: 1.0.0 + resolution: "ngraph.merge@npm:1.0.0" + checksum: e4ad9e55acec7704229f56e7d6157b17a9d87736efdbd2cee6c3fab92cffb99e94b3d8e2e5e75f863f4fe972192a2bff1381e58334115d7c9c08b55b1673801f + languageName: node + linkType: hard + +"ngraph.random@npm:^1.0.0": + version: 1.1.0 + resolution: "ngraph.random@npm:1.1.0" + checksum: 926ed86450d2fc983aea92e22f595bf064c45d90e5efbf4bd85f14e825253c9e01417ef1357da0875691ccb61ec411443a7c8e1ec10885f2eb841366b3bd7960 + languageName: node + linkType: hard + "no-case@npm:^3.0.4": version: 3.0.4 resolution: "no-case@npm:3.0.4" @@ -11390,6 +11607,15 @@ __metadata: languageName: node linkType: hard +"polished@npm:4": + version: 4.2.2 + resolution: "polished@npm:4.2.2" + dependencies: + "@babel/runtime": ^7.17.8 + checksum: 97fb927dc55cd34aeb11b31ae2a3332463f114351c86e8aa6580d7755864a0120164fdc3770e6160c8b1775052f0eda14db9a6e34402cd4b08ab2d658a593725 + languageName: node + linkType: hard + "postcss-attribute-case-insensitive@npm:^6.0.2": version: 6.0.2 resolution: "postcss-attribute-case-insensitive@npm:6.0.2" @@ -12342,7 +12568,7 @@ __metadata: languageName: node linkType: hard -"prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": +"prop-types@npm:15, prop-types@npm:^15.6.2, prop-types@npm:^15.7.2, prop-types@npm:^15.8.1": version: 15.8.1 resolution: "prop-types@npm:15.8.1" dependencies: @@ -12486,6 +12712,19 @@ __metadata: languageName: node linkType: hard +"react-force-graph-3d@npm:^1.24.0": + version: 1.24.0 + resolution: "react-force-graph-3d@npm:1.24.0" + dependencies: + 3d-force-graph: ^1.73 + prop-types: 15 + react-kapsule: 2 + peerDependencies: + react: "*" + checksum: 5084491aac5629a38176c4fef52513e423cddb4d98a41f445851bedb1895f1800e3ab04e908b5ad39544bb13610a214518348465c0baf7d6d7c709fa0024e179 + languageName: node + linkType: hard + "react-intersection-observer@npm:^9.4.1": version: 9.5.2 resolution: "react-intersection-observer@npm:9.5.2" @@ -12533,6 +12772,18 @@ __metadata: languageName: node linkType: hard +"react-kapsule@npm:2": + version: 2.4.0 + resolution: "react-kapsule@npm:2.4.0" + dependencies: + fromentries: ^1.3.2 + jerrypick: ^1.1.1 + peerDependencies: + react: ">=16.13.1" + checksum: 8d9af1c8e3329991a90cb5f2a6e64ee0085016e00de7783a58c6a95089ff710a07c0acaf6b2b6b4d393802e9448e7263f3324486f1358926706e8075e7d5cd3a + languageName: node + linkType: hard + "react-lifecycles-compat@npm:^3.0.4": version: 3.0.4 resolution: "react-lifecycles-compat@npm:3.0.4" @@ -14217,6 +14468,47 @@ __metadata: languageName: node linkType: hard +"three-forcegraph@npm:1": + version: 1.41.10 + resolution: "three-forcegraph@npm:1.41.10" + dependencies: + accessor-fn: 1 + d3-array: 1 - 3 + d3-force-3d: 2 - 3 + d3-scale: 1 - 4 + d3-scale-chromatic: 1 - 3 + data-joint: 1 + kapsule: 1 + ngraph.forcelayout: 3 + ngraph.graph: 20 + tinycolor2: 1 + peerDependencies: + three: ">=0.118.3" + checksum: 921f3f7abc60816738aefde97946b51b00d06dba4e0f04b5fd9678d3dfa375579048008fe6f2994208dc09e359f0c17431f65cf5f7569b6ff7907919a7c61d41 + languageName: node + linkType: hard + +"three-render-objects@npm:^1.29": + version: 1.29.0 + resolution: "three-render-objects@npm:1.29.0" + dependencies: + "@tweenjs/tween.js": 18 - 21 + accessor-fn: 1 + kapsule: 1 + polished: 4 + peerDependencies: + three: "*" + checksum: 90824e427aa4605d9b52844a6a021ac323cd5890a02e5f9ed1651c23304366e57c1670d3cd607ac18a867a852be977127b4303729aabd586791e73c8b39b089c + languageName: node + linkType: hard + +"three@npm:>=0.118 <1, three@npm:^0.157.0": + version: 0.157.0 + resolution: "three@npm:0.157.0" + checksum: 444797461c9db09d8a4cad886e494c2e6dd5754f09ac7ac4af75a3bf1143ae79641388db31999edcc76c5dba9677639f09da2df8d0128396a1dd59e41226c85c + languageName: node + linkType: hard + "through@npm:^2.3.6": version: 2.3.8 resolution: "through@npm:2.3.8" @@ -14238,6 +14530,13 @@ __metadata: languageName: node linkType: hard +"tinycolor2@npm:1": + version: 1.6.0 + resolution: "tinycolor2@npm:1.6.0" + checksum: 6df4d07fceeedc0a878d7bac47e2cd47c1ceeb1078340a9eb8a295bc0651e17c750f73d47b3028d829f30b85c15e0572c0fd4142083e4c21a30a597e47f47230 + languageName: node + linkType: hard + "tmp@npm:^0.0.33": version: 0.0.33 resolution: "tmp@npm:0.0.33"