372 lines
11 KiB
TypeScript
372 lines
11 KiB
TypeScript
import { ReactNode, useEffect, useRef, useState } from "react";
|
|
import { apiAuth } from "./api";
|
|
import {
|
|
GraphCanvas,
|
|
GraphCanvasRef,
|
|
GraphEdge,
|
|
GraphNode,
|
|
SelectionProps,
|
|
SelectionResult,
|
|
useSelection,
|
|
} from "reagraph";
|
|
import { customTheme } from "./NetworkTheme";
|
|
import { useSession } from "./Session";
|
|
import { useNavigate } from "react-router";
|
|
import { ChevronDown, ChevronUp, Info, Settings2 } from "lucide-react";
|
|
|
|
interface NetworkData {
|
|
nodes: GraphNode[];
|
|
edges: GraphEdge[];
|
|
}
|
|
interface CustomSelectionProps extends SelectionProps {
|
|
ignore: (GraphEdge | undefined)[];
|
|
}
|
|
|
|
const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
|
|
var result = useSelection(props);
|
|
result.actives = result.actives.filter(
|
|
(s) => !props.ignore.map((edge) => edge?.id).includes(s)
|
|
);
|
|
const ignored_nodes = props.ignore.map((edge) =>
|
|
edge &&
|
|
result.selections?.includes(edge.source) &&
|
|
!result.selections?.includes(edge.target)
|
|
? edge.target
|
|
: ""
|
|
);
|
|
result.actives = result.actives.filter((s) => !ignored_nodes.includes(s));
|
|
return result;
|
|
};
|
|
|
|
export const GraphComponent = () => {
|
|
let initialData = { nodes: [], edges: [] } as NetworkData;
|
|
const [data, setData] = useState(initialData);
|
|
const [loading, setLoading] = useState(true);
|
|
const [error, setError] = useState("");
|
|
const [threed, setThreed] = useState(false);
|
|
const [showControls, setShowControls] = useState(true);
|
|
const [likes, setLikes] = useState(2);
|
|
const [showLikes, setShowLikes] = useState(true);
|
|
const [showDislikes, setShowDislikes] = useState(false);
|
|
const [popularity, setPopularity] = useState(false);
|
|
const [mutuality, setMutuality] = useState(false);
|
|
const [showHelp, setShowHelp] = useState(false);
|
|
const { user, teams } = useSession();
|
|
const navigate = useNavigate();
|
|
useEffect(() => {
|
|
user?.scopes.includes(`team:${teams?.activeTeam}`) ||
|
|
teams?.activeTeam === 42 ||
|
|
navigate("/", { replace: true });
|
|
}, [user]);
|
|
|
|
async function loadData() {
|
|
setLoading(true);
|
|
if (teams) {
|
|
await apiAuth(`analysis/graph_json/${teams?.activeTeam}`, null)
|
|
.then((data) => {
|
|
if (data.detail) {
|
|
setError(data.detail);
|
|
return initialData;
|
|
} else {
|
|
setError("");
|
|
return data as Promise<NetworkData>;
|
|
}
|
|
})
|
|
.then((data) => setData(data))
|
|
.catch(() => setError("no access"));
|
|
setLoading(false);
|
|
} else setError("team unknown");
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadData();
|
|
}, [teams]);
|
|
|
|
const graphRef = useRef<GraphCanvasRef | null>(null);
|
|
|
|
function handleThreed() {
|
|
setThreed(!threed);
|
|
graphRef.current?.fitNodesInView();
|
|
graphRef.current?.centerGraph();
|
|
graphRef.current?.resetControls();
|
|
}
|
|
|
|
function handlePopularity() {
|
|
popularityLabel(!popularity);
|
|
setPopularity(!popularity);
|
|
}
|
|
|
|
function handleMutuality() {
|
|
colorMatches(!mutuality);
|
|
setMutuality(!mutuality);
|
|
}
|
|
|
|
function findMatches(edges: GraphEdge[]) {
|
|
const adjacencyList = edges.map(
|
|
(edge) => edge.source + edge.target + edge.data.relation
|
|
);
|
|
return edges.filter((edge) =>
|
|
adjacencyList.includes(edge.target + edge.source + edge.data.relation)
|
|
);
|
|
}
|
|
//const matches = useMemo(() => findMatches(data.edges), [])
|
|
|
|
function colorMatches(mutuality: boolean) {
|
|
const matches = findMatches(data.edges);
|
|
const newEdges = data.edges;
|
|
if (mutuality) {
|
|
newEdges.forEach((edge) => {
|
|
if (
|
|
(likes === 1 || edge.data.relation === likes) &&
|
|
matches.map((edge) => edge.id).includes(edge.id)
|
|
) {
|
|
edge.fill = "#9c3";
|
|
if (edge.size) edge.size = edge.size * 1.5;
|
|
}
|
|
});
|
|
} else {
|
|
newEdges.forEach((edge) => {
|
|
if (
|
|
(likes === 1 || edge.data.relation === likes) &&
|
|
matches.map((edge) => edge.id).includes(edge.id)
|
|
) {
|
|
edge.fill = edge.data.origFill;
|
|
edge.size = edge.data.origSize;
|
|
}
|
|
});
|
|
}
|
|
setData({ nodes: data.nodes, edges: newEdges });
|
|
}
|
|
|
|
function popularityLabel(popularity: boolean) {
|
|
const newNodes = data.nodes;
|
|
console.log(data.nodes);
|
|
if (popularity) {
|
|
newNodes.forEach(
|
|
(node) => (node.subLabel = `pop.: ${node.data.inDegree.toFixed(1)}`)
|
|
);
|
|
} else {
|
|
newNodes.forEach((node) => (node.subLabel = undefined));
|
|
}
|
|
setData({ nodes: newNodes, edges: data.edges });
|
|
}
|
|
|
|
useEffect(() => {
|
|
setLikes(+showLikes + +!showDislikes);
|
|
}, [showLikes, showDislikes]);
|
|
|
|
useEffect(() => {
|
|
if (mutuality) colorMatches(false);
|
|
colorMatches(mutuality);
|
|
}, [likes]);
|
|
|
|
const {
|
|
selections,
|
|
actives,
|
|
onNodeClick,
|
|
onCanvasClick,
|
|
onNodePointerOver,
|
|
onNodePointerOut,
|
|
} = useCustomSelection({
|
|
ref: graphRef,
|
|
nodes: data.nodes,
|
|
edges: data.edges.filter((edge) => edge.data.relation === likes),
|
|
ignore: data.edges.map((edge) => {
|
|
if (likes === 1 && edge.data.relation !== 2) return edge;
|
|
}),
|
|
pathSelectionType: "out",
|
|
pathHoverType: "in",
|
|
type: "multiModifier",
|
|
});
|
|
|
|
let content: ReactNode;
|
|
if (loading) {
|
|
<div className="container">
|
|
<progress className="progress is-primary" max="100"></progress>
|
|
</div>;
|
|
} else if (error) {
|
|
content = <span>{error}</span>;
|
|
} else {
|
|
content = (
|
|
<>
|
|
<GraphCanvas
|
|
draggable
|
|
cameraMode={threed ? "rotate" : "pan"}
|
|
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
|
|
layoutOverrides={{
|
|
nodeStrength: -200,
|
|
linkDistance: 100,
|
|
}}
|
|
labelType="nodes"
|
|
sizingType="attribute"
|
|
sizingAttribute={popularity ? "inDegree" : undefined}
|
|
ref={graphRef}
|
|
theme={customTheme}
|
|
nodes={data.nodes}
|
|
edges={data.edges.filter(
|
|
(edge) => edge.data.relation === likes || likes === 1
|
|
)}
|
|
selections={selections}
|
|
actives={actives}
|
|
onCanvasClick={onCanvasClick}
|
|
onNodeClick={onNodeClick}
|
|
onNodePointerOut={onNodePointerOut}
|
|
onNodePointerOver={onNodePointerOver}
|
|
/>
|
|
</>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div>
|
|
<div className="card network-controls">
|
|
<header
|
|
className="card-header"
|
|
style={{ cursor: "pointer" }}
|
|
onClick={() => setShowControls(!showControls)}
|
|
>
|
|
<p className="card-header-title">
|
|
<span className="icon-text">
|
|
<span className="icon">
|
|
<Settings2 />
|
|
</span>
|
|
<span>controls</span>
|
|
</span>
|
|
</p>
|
|
<button className="card-header-icon">
|
|
{showControls ? <ChevronUp /> : <ChevronDown />}
|
|
</button>
|
|
</header>
|
|
{showControls && (
|
|
<div className="card-content">
|
|
<div className="field">
|
|
<div className="field is-grouped">
|
|
<div className="control">
|
|
<button
|
|
className={
|
|
"button is-primary" + (showLikes ? "" : " is-outlined")
|
|
}
|
|
onClick={() => setShowLikes(!showLikes)}
|
|
>
|
|
<span className="icon">👍</span>
|
|
</button>
|
|
</div>
|
|
<div className="control">
|
|
<button
|
|
className={
|
|
"button is-primary" + (showDislikes ? "" : " is-outlined")
|
|
}
|
|
onClick={() => setShowDislikes(!showDislikes)}
|
|
>
|
|
<span className="icon">👎</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="control">
|
|
<label className="checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={mutuality}
|
|
onClick={handleMutuality}
|
|
/>
|
|
<span className="ml-1">mutuality</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="control">
|
|
<label className="radio">
|
|
<input
|
|
type="radio"
|
|
checked={!threed}
|
|
name="3D"
|
|
onClick={handleThreed}
|
|
/>
|
|
<span className="m-1">2D</span>
|
|
</label>
|
|
<label className="radio">
|
|
<input
|
|
type="radio"
|
|
checked={threed}
|
|
name="3D"
|
|
onClick={handleThreed}
|
|
/>
|
|
<span className="m-1">3D</span>
|
|
</label>
|
|
</div>
|
|
|
|
<div className="control">
|
|
<label className="checkbox">
|
|
<input
|
|
type="checkbox"
|
|
checked={popularity}
|
|
onClick={handlePopularity}
|
|
/>
|
|
<span className="ml-1">popularity</span>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
{showControls && (
|
|
<footer className="card-footer">
|
|
<button
|
|
onClick={() => {
|
|
setShowHelp(true);
|
|
}}
|
|
className="card-footer-item"
|
|
>
|
|
<p className="icon-text is-small">
|
|
<span className="icon">
|
|
<Info />
|
|
</span>
|
|
<span>help</span>
|
|
</p>
|
|
</button>
|
|
</footer>
|
|
)}
|
|
</div>
|
|
|
|
<div className={"modal" + (showHelp ? " is-active" : "")}>
|
|
<div
|
|
onClick={() => setShowHelp(false)}
|
|
className="modal-background"
|
|
></div>
|
|
<div className="modal-content">
|
|
<article className="message is-info">
|
|
<div className="message-header">
|
|
<p>Help</p>
|
|
<button
|
|
onClick={() => setShowHelp(false)}
|
|
className="delete"
|
|
aria-label="delete"
|
|
></button>
|
|
</div>
|
|
<div className="message-body">
|
|
<p>scroll to zoom</p>
|
|
<p>
|
|
<strong>hover</strong>: show inbound links
|
|
</p>
|
|
<p>
|
|
<strong>click</strong>: show outward links
|
|
</p>
|
|
<hr className="has-background-info" />
|
|
<p>
|
|
multi-selection is possible with
|
|
<br />
|
|
<i>Ctrl</i> or <i>Shift</i>
|
|
</p>
|
|
<br />
|
|
<p>drag to pan/rotate</p>
|
|
<hr className="has-background-info" />
|
|
<p>popularity is meassured by rank-weighted in-degree</p>
|
|
</div>
|
|
</article>
|
|
</div>
|
|
</div>
|
|
|
|
{content}
|
|
</div>
|
|
);
|
|
};
|