diff --git a/package.json b/package.json index fd7c913a..8609f10e 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,7 @@ "preact": "^10.17.1", "preact-async-route": "^2.2.1", "preact-router": "^4.1.2", + "react-force-graph-3d": "^1.23.1", "react-helmet": "^6.1.0", "react-string-replace": "^1.1.1" }, diff --git a/src/js/views/NetworkGraph.tsx b/src/js/views/NetworkGraph.tsx new file mode 100644 index 00000000..bdebd468 --- /dev/null +++ b/src/js/views/NetworkGraph.tsx @@ -0,0 +1,228 @@ +import { useEffect, useState } from 'preact/hooks'; +import ForceGraph3D from 'react-force-graph-3d'; +import SocialNetwork from '../nostr/SocialNetwork'; +import { PUB, UserId } from '../nostr/UserIds'; + +interface GraphNode { + id: UserId; + profile?: any; + distance: number; + val: number; + inboundCount: number; + outboundCount: number; + color?: string; + visible: boolean; + // curvature?: number; +} + +interface GraphLink { + source: UserId; + target: UserId; + distance: number; +} + +interface GraphMetadata { + // usersByFollowDistance?: Map>; + userCountByDistance: number[]; + nodes?: Map; +} + +interface GraphData { + nodes: GraphNode[]; + links: GraphLink[]; + meta?: GraphMetadata; +} + +enum Direction { + INBOUND, + OUTBOUND, + BOTH, +} + +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 [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) => SocialNetwork.usersByFollowDistance.get(i)?.size || 0, + ); + + // Go through all the nodes + for (let distance = 0; distance <= showDistance; ++distance) { + const users = SocialNetwork.usersByFollowDistance.get(distance); + if (!users) break; + for (const userID of users) { + if (renderLimit && nodes.size >= renderLimit) break; // Temporary hack + const inboundCount = SocialNetwork.followersByUser.get(userID)?.size || 0; + const outboundCount = SocialNetwork.followedByUser.get(userID)?.size || 0; + const node = { + id: userID, + address: PUB(userID), + profile: SocialNetwork.profiles.get(userID), + 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(userID, node); + } + } + + // Add links + for (const node of nodes.values()) { + if (direction === Direction.OUTBOUND || direction === Direction.BOTH) { + for (const followedID of SocialNetwork.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 SocialNetwork.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 ( + + ); + })} +
+
+ `${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/src/js/views/settings/SocialNetwork.tsx b/src/js/views/settings/SocialNetwork.tsx index 71f8c379..21d55a43 100644 --- a/src/js/views/settings/SocialNetwork.tsx +++ b/src/js/views/settings/SocialNetwork.tsx @@ -7,6 +7,7 @@ import Key from '../../nostr/Key'; import SocialNetwork from '../../nostr/SocialNetwork'; import localState from '../../state/LocalState.ts'; import { translate as t } from '../../translations/Translation.mjs'; +import NetworkGraph from "../NetworkGraph"; const SocialNetworkSettings = () => { const [blockedUsers, setBlockedUsers] = useState([]); @@ -64,6 +65,7 @@ const SocialNetworkSettings = () => { {distance[0] || t('unknown')}: {distance[1].size} users ))} +

Filter incoming events by follow distance: