feat: add footer back to Network page

This commit is contained in:
julius 2025-03-03 11:52:48 +01:00
parent 9d65c1d1df
commit 104ec70695
Signed by: julius
GPG Key ID: C80A63E6A5FD7092
3 changed files with 209 additions and 82 deletions

View File

@ -23,8 +23,27 @@ footer {
font-size: x-small; font-size: x-small;
} }
.fixed-footer {
position: absolute;
bottom: 4px;
left: 8px;
}
/*=========Network Controls=========*/ /*=========Network Controls=========*/
.infobutton {
position: fixed;
right: 8px;
bottom: 8px;
padding: 0.4em;
border-radius: 1em;
background-color: rgba(0, 0, 0, 0.3);
font-size: medium;
margin-bottom: 16px;
margin-right: 16px;
}
.controls { .controls {
z-index: 9; z-index: 9;
position: absolute; position: absolute;

View File

@ -1,13 +1,20 @@
import { Link } from "react-router"; import { Link } from "react-router";
export default function Footer() { export default function Footer() {
return <footer> return (
<footer>
<div className="navbar"> <div className="navbar">
<Link to="/" ><span>Form</span></Link> <a href="/">
<span>Form</span>
</a>
<span>|</span> <span>|</span>
<Link to="/network" ><span>Trainer Analysis</span></Link> <a href="/network">
<span>Trainer Analysis</span>
</a>
<span>|</span> <span>|</span>
<Link to="/mvp" ><span>MVP</span></Link> <a href="/mvp">
<span>MVP</span>
</a>
</div> </div>
<p className="grey extra-margin"> <p className="grey extra-margin">
something not working? something not working?
@ -20,4 +27,5 @@ export default function Footer() {
</a> </a>
</p> </p>
</footer> </footer>
);
} }

View File

@ -1,11 +1,19 @@
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api"; import { apiAuth } from "./api";
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, SelectionProps, SelectionResult, useSelection } from "reagraph"; import {
GraphCanvas,
GraphCanvasRef,
GraphEdge,
GraphNode,
SelectionProps,
SelectionResult,
useSelection,
} from "reagraph";
import { customTheme } from "./NetworkTheme"; import { customTheme } from "./NetworkTheme";
interface NetworkData { interface NetworkData {
nodes: GraphNode[], nodes: GraphNode[];
edges: GraphEdge[], edges: GraphEdge[];
} }
interface CustomSelectionProps extends SelectionProps { interface CustomSelectionProps extends SelectionProps {
ignore: (GraphEdge | undefined)[]; ignore: (GraphEdge | undefined)[];
@ -13,11 +21,19 @@ interface CustomSelectionProps extends SelectionProps {
const useCustomSelection = (props: CustomSelectionProps): SelectionResult => { const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
var result = useSelection(props); var result = useSelection(props);
result.actives = result.actives.filter((s) => !props.ignore.map((edge) => edge?.id).includes(s)) result.actives = result.actives.filter(
const ignored_nodes = props.ignore.map((edge) => (edge && result.selections?.includes(edge.source) && !result.selections?.includes(edge.target)) ? edge.target : "") (s) => !props.ignore.map((edge) => edge?.id).includes(s)
result.actives = result.actives.filter((s) => !ignored_nodes.includes(s)) );
return result 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 = () => { export const GraphComponent = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData); const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
@ -26,31 +42,41 @@ export const GraphComponent = () => {
const [likes, setLikes] = useState(2); const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false); const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false); const [mutuality, setMutuality] = useState(false);
const logo = document.getElementById("logo")
const logo = document.getElementById("logo");
if (logo) { if (logo) {
logo.className = "logo networkroute"; logo.className = "logo networkroute";
} }
const footer = document.getElementsByTagName("footer");
if (footer) {
(footer.item(0) as HTMLElement).className = "fixed-footer";
}
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
await apiAuth("analysis/graph_json", null) await apiAuth("analysis/graph_json", null)
.then(json => json as Promise<NetworkData>).then(json => { setData(json) }) .then((json) => json as Promise<NetworkData>)
.then((json) => {
setData(json);
});
setLoading(false); setLoading(false);
} }
useEffect(() => { loadData() }, []) useEffect(() => {
loadData();
}, []);
const graphRef = useRef<GraphCanvasRef | null>(null); const graphRef = useRef<GraphCanvasRef | null>(null);
function handleThreed() { function handleThreed() {
setThreed(!threed) setThreed(!threed);
graphRef.current?.fitNodesInView(); graphRef.current?.fitNodesInView();
graphRef.current?.centerGraph(); graphRef.current?.centerGraph();
graphRef.current?.resetControls(); graphRef.current?.resetControls();
} }
function handlePopularity() { function handlePopularity() {
setPopularity(!popularity) setPopularity(!popularity);
} }
function handleMutuality() { function handleMutuality() {
@ -60,15 +86,22 @@ export const GraphComponent = () => {
function showLabel() { function showLabel() {
switch (likes) { switch (likes) {
case 0: return "dislike"; case 0:
case 1: return "both"; return "dislike";
case 2: return "like"; case 1:
return "both";
case 2:
return "like";
} }
} }
function findMatches(edges: GraphEdge[]) { function findMatches(edges: GraphEdge[]) {
const adjacencyList = edges.map((edge) => edge.source + edge.target + edge.data.relation); const adjacencyList = edges.map(
return edges.filter((edge) => adjacencyList.includes(edge.target + edge.source + edge.data.relation)) (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), []) //const matches = useMemo(() => findMatches(data.edges), [])
@ -76,31 +109,55 @@ export const GraphComponent = () => {
const matches = findMatches(data.edges); const matches = findMatches(data.edges);
const newEdges = data.edges; const newEdges = data.edges;
if (mutuality) { 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 } }) newEdges.forEach((edge) => {
} else { if (
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 } }) (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;
} }
setData({ nodes: data.nodes, edges: newEdges }) });
} 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 });
} }
useEffect(() => { useEffect(() => {
if (mutuality) colorMatches(false); if (mutuality) colorMatches(false);
colorMatches(mutuality) colorMatches(mutuality);
}, [likes]) }, [likes]);
const { selections, actives, onNodeClick, onCanvasClick } = useCustomSelection({ const {
selections,
actives,
onNodeClick,
onCanvasClick,
onNodePointerOver,
onNodePointerOut,
} = useCustomSelection({
ref: graphRef, ref: graphRef,
nodes: data.nodes, nodes: data.nodes,
edges: data.edges.filter((edge) => edge.data.relation === likes), edges: data.edges.filter((edge) => edge.data.relation === likes),
ignore: data.edges.map((edge) => { if (likes === 1 && edge.data.relation !== 2) return edge }), ignore: data.edges.map((edge) => {
pathSelectionType: 'out', if (likes === 1 && edge.data.relation !== 2) return edge;
type: 'multiModifier' }),
pathSelectionType: "out",
pathHoverType: "in",
type: "multiModifier",
}); });
return ( return (
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}> <div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls"> <div className="controls">
<div className="control" onClick={handleMutuality}> <div className="control" onClick={handleMutuality}>
<div className="switch"> <div className="switch">
<input type="checkbox" checked={mutuality} /> <input type="checkbox" checked={mutuality} />
@ -147,24 +204,32 @@ export const GraphComponent = () => {
<input type="checkbox" checked={popularity} /> <input type="checkbox" checked={popularity} />
<span className="slider round"></span> <span className="slider round"></span>
</div> </div>
<span>popularity<sup>*</sup></span> <span>
popularity<sup>*</sup>
</span>
</div>
</div> </div>
{popularity && (
<div
style={{ position: "absolute", bottom: 0, right: "10px", zIndex: 10 }}
>
<span className="grey" style={{ fontSize: "70%" }}>
<sup>*</sup>popularity meassured by rank-weighted in-degree
</span>
</div> </div>
)}
{popularity && <div style={{ position: 'absolute', bottom: 0, right: "10px", zIndex: 10 }}> {loading ? (
<span className="grey" style={{ fontSize: "70%" }}><sup>*</sup>popularity meassured by rank-weighted in-degree</span> <span className="loader" />
</div>} ) : (
{
loading ? <span className="loader" /> :
<GraphCanvas <GraphCanvas
draggable draggable
cameraMode={threed ? "rotate" : "pan"} cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"} layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{ layoutOverrides={{
nodeStrength: -200, nodeStrength: -200,
linkDistance: 100 linkDistance: 100,
}} }}
labelType="nodes" labelType="nodes"
sizingType="attribute" sizingType="attribute"
@ -172,14 +237,49 @@ export const GraphComponent = () => {
ref={graphRef} ref={graphRef}
theme={customTheme} theme={customTheme}
nodes={data.nodes} nodes={data.nodes}
edges={data.edges.filter((edge) => edge.data.relation === likes || likes === 1)} edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections} selections={selections}
actives={actives} actives={actives}
onCanvasClick={onCanvasClick} onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick} onNodeClick={onNodeClick}
onNodePointerOut={onNodePointerOut}
onNodePointerOver={onNodePointerOver}
/> />
} )}
<button
className="infobutton"
onClick={() => {
const dialog = document.querySelector("dialog[id='InfoDialog']");
(dialog as HTMLDialogElement).showModal();
}}
>
info
</button>
<dialog
id="InfoDialog"
style={{ textAlign: "left" }}
onClick={(event) => {
event.currentTarget.close();
}}
>
scroll to zoom
<br />
<br />
<b>hover</b>: show inbound links
<br />
<b>click</b>: show outward links
<br />
<br />
multi-selection possible
<br />
with <i>Ctrl</i> or <i>Shift</i>
<br />
<br />
drag to pan/rotate
</dialog>
</div> </div>
); );
} };