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;
}
.fixed-footer {
position: absolute;
bottom: 4px;
left: 8px;
}
/*=========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 {
z-index: 9;
position: absolute;

View File

@ -1,23 +1,31 @@
import { Link } from "react-router";
export default function Footer() {
return <footer>
<div className="navbar">
<Link to="/" ><span>Form</span></Link>
<span>|</span>
<Link to="/network" ><span>Trainer Analysis</span></Link>
<span>|</span>
<Link to="/mvp" ><span>MVP</span></Link>
</div>
<p className="grey extra-margin">
something not working?
<br />
message <a href="https://t.me/x0124816">me</a>.
<br />
or fix it here:{" "}
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
<img src="gitea.svg" alt="gitea" height="16" />
</a>
</p>
</footer>
return (
<footer>
<div className="navbar">
<a href="/">
<span>Form</span>
</a>
<span>|</span>
<a href="/network">
<span>Trainer Analysis</span>
</a>
<span>|</span>
<a href="/mvp">
<span>MVP</span>
</a>
</div>
<p className="grey extra-margin">
something not working?
<br />
message <a href="https://t.me/x0124816">me</a>.
<br />
or fix it here:{" "}
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
<img src="gitea.svg" alt="gitea" height="16" />
</a>
</p>
</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 { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, SelectionProps, SelectionResult, useSelection } from "reagraph";
import {
GraphCanvas,
GraphCanvasRef,
GraphEdge,
GraphNode,
SelectionProps,
SelectionResult,
useSelection,
} from "reagraph";
import { customTheme } from "./NetworkTheme";
interface NetworkData {
nodes: GraphNode[],
edges: GraphEdge[],
nodes: GraphNode[];
edges: GraphEdge[];
}
interface CustomSelectionProps extends SelectionProps {
ignore: (GraphEdge | undefined)[];
@ -13,11 +21,19 @@ interface CustomSelectionProps extends SelectionProps {
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
}
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 = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
@ -26,31 +42,41 @@ export const GraphComponent = () => {
const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false);
const [mutuality, setMutuality] = useState(false);
const logo = document.getElementById("logo")
const logo = document.getElementById("logo");
if (logo) {
logo.className = "logo networkroute";
}
const footer = document.getElementsByTagName("footer");
if (footer) {
(footer.item(0) as HTMLElement).className = "fixed-footer";
}
async function loadData() {
setLoading(true);
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);
}
useEffect(() => { loadData() }, [])
useEffect(() => {
loadData();
}, []);
const graphRef = useRef<GraphCanvasRef | null>(null);
function handleThreed() {
setThreed(!threed)
setThreed(!threed);
graphRef.current?.fitNodesInView();
graphRef.current?.centerGraph();
graphRef.current?.resetControls();
}
function handlePopularity() {
setPopularity(!popularity)
setPopularity(!popularity);
}
function handleMutuality() {
@ -60,15 +86,22 @@ export const GraphComponent = () => {
function showLabel() {
switch (likes) {
case 0: return "dislike";
case 1: return "both";
case 2: return "like";
case 0:
return "dislike";
case 1:
return "both";
case 2:
return "like";
}
}
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 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), [])
@ -76,31 +109,55 @@ export const GraphComponent = () => {
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 } })
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 } })
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 })
setData({ nodes: data.nodes, edges: newEdges });
}
useEffect(() => {
if (mutuality) colorMatches(false);
colorMatches(mutuality)
}, [likes])
colorMatches(mutuality);
}, [likes]);
const { selections, actives, onNodeClick, onCanvasClick } = useCustomSelection({
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',
type: 'multiModifier'
ignore: data.edges.map((edge) => {
if (likes === 1 && edge.data.relation !== 2) return edge;
}),
pathSelectionType: "out",
pathHoverType: "in",
type: "multiModifier",
});
return (
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls" >
<div style={{ position: "absolute", top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls">
<div className="control" onClick={handleMutuality}>
<div className="switch">
<input type="checkbox" checked={mutuality} />
@ -147,39 +204,82 @@ export const GraphComponent = () => {
<input type="checkbox" checked={popularity} />
<span className="slider round"></span>
</div>
<span>popularity<sup>*</sup></span>
<span>
popularity<sup>*</sup>
</span>
</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>}
{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>
)}
{
loading ? <span className="loader" /> :
<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}
/>
}
</div >
{loading ? (
<span className="loader" />
) : (
<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}
/>
)}
<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>
);
}
};