import { NodeObject } from "react-force-graph-3d"; import { useContext, useEffect, useState } from "react"; import { MetadataCache, socialGraphInstance, STR, UID } from "@snort/system"; import { SnortContext } from "@snort/system-react"; import { defaultAvatar } from "../SnortUtils"; import { proxyImg } from "@/Hooks/useImgProxy"; import { LoginStore } from "@/Login"; import { FormattedMessage } from "react-intl"; import Icon from "@/Icons/Icon"; 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 [ForceGraph3D, setForceGraph3D] = useState(null); const [THREE, setTHREE] = useState(null); useEffect(() => { // Dynamically import the modules import("react-force-graph-3d").then(module => { setForceGraph3D(module.default); }); import("three").then(module => { setTHREE(module); }); }, []); const handleCloseGraph = () => { setOpen(false); }; const handleKeyDown = (event: { key: string }) => { if (event.key === "Escape") { handleCloseGraph(); } }; useEffect(() => { if (open) { window.addEventListener("keydown", handleKeyDown); } // Cleanup function to remove the event listener return () => { window.removeEventListener("keydown", handleKeyDown); }; }, [open]); 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) => socialGraphInstance.usersByFollowDistance.get(i)?.size || 0, ); // Go through all the nodes for (let distance = 0; distance <= showDistance; ++distance) { const users = socialGraphInstance.usersByFollowDistance.get(distance); if (!users) break; for (const UID of users) { if (renderLimit && nodes.size >= renderLimit) break; // Temporary hack const inboundCount = socialGraphInstance.followersByUser.get(UID)?.size || 0; const outboundCount = socialGraphInstance.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 socialGraphInstance.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 socialGraphInstance.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(); }, []); if (!ForceGraph3D || !THREE) return null; 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={0} nodeVisibility="visible" numDimensions={3} linkDirectionalArrowLength={0} nodeOpacity={0.9} />
Render limit: {graphConfig.renderLimit} nodes
)}
); }; export default NetworkGraph;