Files
cutt/frontend/src/Network.tsx

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>
);
};