300 lines
9.4 KiB
TypeScript
300 lines
9.4 KiB
TypeScript
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<number, Set<UID>>;
|
|
userCountByDistance: number[];
|
|
nodes?: Map<number, GraphNode>;
|
|
}
|
|
|
|
interface GraphData {
|
|
nodes: GraphNode[];
|
|
links: GraphLink[];
|
|
meta?: GraphMetadata;
|
|
}
|
|
|
|
enum Direction {
|
|
INBOUND,
|
|
OUTBOUND,
|
|
BOTH,
|
|
}
|
|
|
|
const avatar = (node: NodeObject<NodeObject<GraphNode>>) => {
|
|
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<GraphConfig>) => {
|
|
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<number, GraphNode>();
|
|
const links: GraphLink[] = [];
|
|
const nodesVisited = new Set<UID>();
|
|
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 (
|
|
<div>
|
|
{!open && (
|
|
<button
|
|
className="btn btn-primary"
|
|
onClick={() => {
|
|
setOpen(true);
|
|
refreshData();
|
|
}}>
|
|
<FormattedMessage defaultMessage="Show graph" id="ha8JKG" />
|
|
</button>
|
|
)}
|
|
{open && graphData && (
|
|
<div className="fixed top-0 left-0 right-0 bottom-0 z-20">
|
|
<button className="absolute top-6 right-6 z-30 btn hover:bg-gray-900" onClick={handleCloseGraph}>
|
|
<Icon name="x" size={24} />
|
|
</button>
|
|
<div className="absolute top-6 right-0 left-0 z-20 flex flex-col content-center justify-center text-center">
|
|
<div className="text-center pb-2">Degrees of separation</div>
|
|
<div className="flex flex-row justify-center space-x-4">
|
|
{graphData.meta?.userCountByDistance?.map((value, i) => {
|
|
if (i === 0 || value <= 0) return null;
|
|
const isSelected = graphConfig.showDistance === i;
|
|
return (
|
|
<button
|
|
key={i}
|
|
className={`btn bg-gray-900 py-4 h-auto flex-col ${
|
|
isSelected ? "bg-gray-600 hover:bg-gray-600" : "hover:bg-gray-800"
|
|
}`}
|
|
onClick={() => isSelected || updateConfig({ showDistance: i })}>
|
|
<div className="text-lg block leading-none">{i}</div>
|
|
<div className="text-xs">({value})</div>
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
<ForceGraph3D
|
|
graphData={graphData}
|
|
nodeThreeObject={(node: NodeObject<NodeObject<GraphNode>>) => {
|
|
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}
|
|
/>
|
|
<div className="absolute bottom-6 right-6">
|
|
<button className="text-lg" onClick={() => toggleConnections()}>
|
|
Showing: {graphConfig.direction === Direction.OUTBOUND ? "Outbound" : "All"} connections
|
|
</button>
|
|
</div>
|
|
<div className="absolute bottom-6 left-6">
|
|
<span className="text-lg">Render limit: {graphConfig.renderLimit} nodes</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
};
|
|
|
|
export default NetworkGraph;
|