Compare commits

..

11 Commits

11 changed files with 396 additions and 29 deletions

View File

@ -24,10 +24,10 @@ P = Player
def sociogram_json(): def sociogram_json():
nodes = [] nodes = []
necessary_nodes = set() necessary_nodes = set()
links = [] edges = []
with Session(engine) as session: with Session(engine) as session:
for p in session.exec(select(P)).fetchall(): for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "appearance": 1}) nodes.append({"id": p.name, "label": p.name})
subquery = ( subquery = (
select(C.user, func.max(C.time).label("latest")) select(C.user, func.max(C.time).label("latest"))
.where(C.time > datetime(2025, 2, 1, 10)) .where(C.time > datetime(2025, 2, 1, 10))
@ -44,9 +44,58 @@ def sociogram_json():
# G.add_edge(c.user, p) # G.add_edge(c.user, p)
# p_id = session.exec(select(P.id).where(P.name == p)).one() # p_id = session.exec(select(P.id).where(P.name == p)).one()
necessary_nodes.add(p) necessary_nodes.add(p)
links.append({"source": c.user, "target": p}) edges.append({"from": c.user, "to": p, "relation": "likes"})
for p in c.hate:
edges.append({"from": c.user, "to": p, "relation": "dislikes"})
# nodes = [n for n in nodes if n["name"] in necessary_nodes] # nodes = [n for n in nodes if n["name"] in necessary_nodes]
return JSONResponse({"nodes": nodes, "links": links}) return JSONResponse({"nodes": nodes, "edges": edges})
def graph_json():
nodes = []
edges = []
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "label": p.name})
subquery = (
select(C.user, func.max(C.time).label("latest"))
.where(C.time > datetime(2025, 2, 1, 10))
.group_by(C.user)
.subquery()
)
statement2 = select(C).join(
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
for i, p in enumerate(c.love):
edges.append(
{
"id": f"{c.user}->{p}",
"source": c.user,
"target": p,
"size": max(1.0 - 0.1 * i, 0.3),
"data": {"relation": 2},
}
)
for p in c.hate:
edges.append(
{
"id": f"{c.user}-x>{p}",
"source": c.user,
"target": p,
"size": 0.3,
"data": {"relation": 0},
"fill": "#ff7c7c",
}
)
G = nx.DiGraph()
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
in_degrees = G.in_degree(weight="weight")
nodes = [
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
]
return JSONResponse({"nodes": nodes, "edges": edges})
def sociogram_data(show: int | None = 2): def sociogram_data(show: int | None = 2):
@ -144,6 +193,7 @@ async def render_sociogram(params: Params):
analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"]) analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"])
analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"]) analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
if __name__ == "__main__": if __name__ == "__main__":

2
db.py
View File

@ -12,7 +12,7 @@ from sqlmodel import (
with open("db.secrets", "r") as f: with open("db.secrets", "r") as f:
db_secrets = f.readline().strip() db_secrets = f.readline().strip()
engine = create_engine(db_secrets) engine = create_engine(db_secrets, connect_args={"connect_timeout": 8})
del db_secrets del db_secrets

View File

@ -10,15 +10,14 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"d3": "^7.9.0",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-sortablejs": "^6.1.4", "react-sortablejs": "^6.1.4",
"reagraph": "^4.21.2",
"sortablejs": "^1.15.6" "sortablejs": "^1.15.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.17.0", "@eslint/js": "^9.17.0",
"@types/d3": "^7.4.3",
"@types/react": "^18.3.18", "@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5", "@types/react-dom": "^18.3.5",
"@types/sortablejs": "^1.15.8", "@types/sortablejs": "^1.15.8",

View File

@ -9,6 +9,7 @@ from db import engine, User
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError
class Config(BaseSettings): class Config(BaseSettings):
@ -47,10 +48,13 @@ def get_password_hash(password):
def get_user(username: str | None): def get_user(username: str | None):
if username: if username:
with Session(engine) as session: try:
return session.exec( with Session(engine) as session:
select(User).where(User.username == username) return session.exec(
).one_or_none() select(User).where(User.username == username)
).one_or_none()
except OperationalError:
return
def authenticate_user(username: str, password: str): def authenticate_user(username: str, password: str):

View File

@ -23,6 +23,87 @@ footer {
font-size: x-small; font-size: x-small;
} }
/*=========Network Controls=========*/
.controls {
z-index: 9;
position: absolute;
width: 240px;
right: 24px;
top: 1vh;
padding: 16px;
.control {
display: flex;
flex-direction: row;
margin: 4px 2px;
background-color: aliceblue;
* {
margin: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 68px;
height: 42px;
}
/* Hide default HTML checkbox */
.switch input {
opacity: 0;
width: 0;
height: 0;
}
/* The slider */
.slider {
position: absolute;
cursor: pointer;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: #ccc;
border-radius: 34px;
-webkit-transition: .4s;
transition: .4s;
}
.slider:before {
position: absolute;
content: "";
height: 26px;
width: 26px;
left: 4px;
bottom: 4px;
background-color: white;
border-radius: 50%;
-webkit-transition: .4s;
transition: .4s;
}
input:checked+.slider {
background-color: #2196F3;
}
input:focus+.slider {
box-shadow: 0 0 1px #2196F3;
}
input:checked+.slider:before {
-webkit-transform: translateX(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
.grey { .grey {
color: #444; color: #444;
@ -168,13 +249,24 @@ button,
#control-panel { #control-panel {
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
} }
.control {
font-size: 80%;
margin: 0px;
}
} }
@media only screen and (max-width: 768px) { @media only screen and (max-width: 768px) {
#control-panel { #control-panel {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.networkroute {
display: none;
}
.submit_text { .submit_text {
display: none; display: none;
} }
@ -241,6 +333,8 @@ button,
font-size: 150%; font-size: 150%;
} }
/*======LOGO=======*/
.logo { .logo {
position: relative; position: relative;
text-align: center; text-align: center;
@ -268,6 +362,15 @@ button,
} }
} }
.networkroute {
z-index: 10;
position: absolute;
top: 24px;
left: 48px;
}
/*======SPINNER=======*/
.loader { .loader {
display: block; display: block;
position: relative; position: relative;

View File

@ -5,19 +5,19 @@ import Header from "./Header";
import Rankings from "./Rankings"; import Rankings from "./Rankings";
import { BrowserRouter, Routes, Route } from "react-router"; import { BrowserRouter, Routes, Route } from "react-router";
import { SessionProvider } from "./Session"; import { SessionProvider } from "./Session";
import { GraphComponent } from "./Network";
function App() { function App() {
//const [data, setData] = useState({ nodes: [], links: [] } as SociogramData);
//async function loadData() {
// await fetch(`${baseUrl}api/analysis/json`, { method: "GET" }).then(resp => resp.json() as unknown as SociogramData).then(json => { setData(json) })
//}
//useEffect(() => { loadData() }, [])
//
return ( return (
<BrowserRouter> <BrowserRouter>
<Header /> <Header />
<Routes> <Routes>
<Route index element={<Rankings />} /> <Route index element={<Rankings />} />
<Route path="/network" element={
<SessionProvider>
<GraphComponent />
</SessionProvider>
} />
<Route path="/analysis" element={ <Route path="/analysis" element={
<SessionProvider> <SessionProvider>
<Analysis /> <Analysis />

View File

@ -5,7 +5,7 @@ export default function Footer() {
<div className="navbar"> <div className="navbar">
<Link to="/" ><span>Form</span></Link> <Link to="/" ><span>Form</span></Link>
<span>|</span> <span>|</span>
<Link to="/analysis" ><span>Trainer Analysis</span></Link> <Link to="/network" ><span>Trainer Analysis</span></Link>
</div> </div>
<p className="grey extra-margin"> <p className="grey extra-margin">
something not working? something not working?

View File

@ -1,8 +1,6 @@
import { baseUrl } from "./api";
export default function Header() { export default function Header() {
return <div className="logo"> return <div className="logo" id="logo">
<a href={baseUrl}> <a href={"/"}>
<img alt="logo" height="66%" src="logo.svg" /> <img alt="logo" height="66%" src="logo.svg" />
<h3 className="centered">cutt</h3> <h3 className="centered">cutt</h3>
</a> </a>

151
src/Network.tsx Normal file
View File

@ -0,0 +1,151 @@
import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api";
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, SelectionProps, SelectionResult, useSelection } from "reagraph";
import { customTheme } from "./NetworkTheme";
interface NetworkData {
nodes: GraphNode[],
edges: GraphEdge[],
}
interface CustomSelectionProps extends SelectionProps {
ignore: string[];
}
const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
var result = useSelection(props);
result.actives = result.actives.filter((s) => !props.ignore.includes(s))
return result
}
export const GraphComponent = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
const [loading, setLoading] = useState(true);
const [threed, setThreed] = useState(false);
const [likes, setLikes] = useState(2);
const [popularity, setPopularity] = useState(false);
const logo = document.getElementById("logo")
if (logo) {
logo.className = "logo networkroute";
}
async function loadData() {
setLoading(true);
await apiAuth("analysis/graph_json", null)
.then(json => json as Promise<NetworkData>).then(json => { setData(json) })
setLoading(false);
}
useEffect(() => { loadData() }, [])
const graphRef = useRef<GraphCanvasRef | null>(null);
function handleThreed() {
setThreed(!threed)
graphRef.current?.fitNodesInView();
graphRef.current?.centerGraph();
graphRef.current?.resetControls();
}
function handlePopularity() {
setPopularity(!popularity)
//graphRef.current?.fitNodesInView();
//graphRef.current?.centerGraph();
//graphRef.current?.resetControls();
}
function showLabel() {
switch (likes) {
case 0: return "dislike";
case 1: return "both";
case 2: return "like";
}
}
const { selections, actives, onNodeClick, onCanvasClick } = useCustomSelection({
ref: graphRef,
nodes: data.nodes,
edges: data.edges.filter((edge) => edge.data.relation === likes),
ignore: data.edges.map((edge) => { return (likes === 1 && edge.data.relation !== 2) ? edge.id : "" }),
pathSelectionType: 'out',
type: 'multiModifier'
});
return (
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls" >
<div className="control" onClick={handleThreed}>
<span>2D</span>
<div className="switch">
<input type="checkbox" checked={threed} />
<span className="slider round"></span>
</div>
<span>3D</span>
</div>
<div className="control">
<div className="stack column">
<datalist id="markers">
<option value="0"></option>
<option value="1"></option>
<option value="2"></option>
</datalist>
<div id="three-slider">
<label>😬</label>
<input
type="range"
list="markers"
min="0"
max="2"
step="1"
width="16px"
onChange={(evt) => setLikes(Number(evt.target.value))}
/>
<label>😍</label>
</div>
{showLabel()}
</div>
</div>
<div className="control" onClick={handlePopularity}>
<div className="switch">
<input type="checkbox" checked={popularity} />
<span className="slider round"></span>
</div>
<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>}
{
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 >
);
}

59
src/NetworkTheme.tsx Normal file
View File

@ -0,0 +1,59 @@
import { Theme } from "reagraph";
export const customTheme: Theme = {
canvas: {
background: 'aliceblue',
},
node: {
fill: '#69F',
activeFill: '#36C',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.333,
label: {
color: '#404040',
stroke: 'white',
activeColor: 'black'
},
subLabel: {
color: '#ddd',
stroke: 'transparent',
activeColor: '#1DE9AC'
}
},
lasso: {
border: '1px solid #55aaff',
background: 'rgba(75, 160, 255, 0.1)'
},
ring: {
fill: '#69F',
activeFill: '#36C'
},
edge: {
fill: '#bed4ff',
activeFill: '#36C',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.333,
label: {
stroke: '#fff',
color: '#2A6475',
activeColor: '#1DE9AC',
fontSize: 6
}
},
arrow: {
fill: '#bed4ff',
activeFill: '#36C'
},
cluster: {
stroke: '#D8E6EA',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.1,
label: {
stroke: '#fff',
color: '#2A6475'
}
}
};

View File

@ -20,13 +20,16 @@ export default async function api(path: string, data: any): Promise<any> {
export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> { export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`, { const req = new Request(`${baseUrl}api/${path}`,
method: method, headers: { {
"Authorization": `Bearer ${token()} `, method: method,
'Content-Type': 'application/json' headers: {
}, "Authorization": `Bearer ${token()} `,
body: JSON.stringify(data), 'Content-Type': 'application/json'
}); },
...(data && { body: JSON.stringify(data) })
}
);
let resp: Response; let resp: Response;
try { try {
resp = await fetch(req); resp = await fetch(req);