feat: simple analysis page with sociogram

This commit is contained in:
julius 2025-02-10 16:12:31 +01:00
parent 8def52fbf2
commit 25bda2bc4d
Signed by: julius
GPG Key ID: C80A63E6A5FD7092
6 changed files with 214 additions and 36 deletions

144
analysis.py Normal file
View File

@ -0,0 +1,144 @@
from datetime import datetime
import numpy as np
import io
import base64
from fastapi import APIRouter
from fastapi.responses import JSONResponse
from pydantic import BaseModel, Field
from sqlmodel import Session, func, select
from sqlmodel.sql.expression import SelectOfScalar
from db import Chemistry, Player, engine
import networkx as nx
import matplotlib
matplotlib.use("agg")
import matplotlib.pyplot as plt
analysis_router = APIRouter(prefix="/analysis")
C = Chemistry
P = Player
def sociogram_json():
nodes = []
necessary_nodes = set()
links = []
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name, "appearance": 1})
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):
# G.add_node(c.user)
necessary_nodes.add(c.user)
for p in c.love:
# G.add_edge(c.user, p)
# p_id = session.exec(select(P.id).where(P.name == p)).one()
necessary_nodes.add(p)
links.append({"source": c.user, "target": p})
# nodes = [n for n in nodes if n["name"] in necessary_nodes]
return JSONResponse({"nodes": nodes, "links": links})
def sociogram_data():
nodes = []
links = []
G = nx.DiGraph()
with Session(engine) as session:
for p in session.exec(select(P)).fetchall():
nodes.append({"id": p.name})
G.add_node(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)
.where(C.user.in_(["Kruse", "Franz", "ck"]))
.join(subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest))
)
for c in session.exec(statement2):
for p in c.love:
G.add_edge(c.user, p)
links.append({"source": c.user, "target": p})
return G
class Params(BaseModel):
node_size: int | None = Field(default=2400, alias="nodeSize")
font_size: int | None = Field(default=10, alias="fontSize")
arrow_size: int | None = Field(default=20, alias="arrowSize")
edge_width: float | None = Field(default=1, alias="edgeWidth")
def sociogram_image(params: Params):
print(params)
plt.figure(figsize=(16, 10), facecolor="none")
ax = plt.gca()
ax.set_facecolor("none") # Set the axis face color to none (transparent)
ax.axis("off") # Turn off axis ticks and frames
G = sociogram_data()
pos = nx.spring_layout(
G, scale=2, k=1 / np.sqrt(G.number_of_edges()), iterations=50, seed=42
)
nx.draw_networkx_nodes(
G,
pos,
node_color="#99ccff",
edgecolors="#404040",
linewidths=1,
node_size=params.node_size,
alpha=0.86,
)
nx.draw_networkx_labels(G, pos, font_size=params.font_size)
nx.draw_networkx_edges(
G,
pos,
arrows=True,
edge_color="#404040",
arrowsize=params.arrow_size,
node_size=params.node_size,
width=params.edge_width,
)
buf = io.BytesIO()
plt.savefig(buf, format="png", bbox_inches="tight", dpi=300, transparent=True)
buf.seek(0)
encoded_image = base64.b64encode(buf.read()).decode("UTF-8")
plt.close()
return {"image": encoded_image}
analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
analysis_router.add_api_route("/image", endpoint=sociogram_image, methods=["POST"])
if __name__ == "__main__":
with Session(engine) as session:
statement: SelectOfScalar[P] = select(func.count(P.id))
print("players in DB: ", session.exec(statement).first())
G = sociogram_data()
pos = nx.spring_layout(G, scale=1, k=2, iterations=50, seed=42)
edges = nx.draw_networkx_edges(
G,
pos,
arrows=True,
arrowsize=12,
)
nx.draw_networkx(
G, pos, with_labels=True, node_color="#99ccff", font_size=8, node_size=2000
)
plt.show()

View File

@ -6,6 +6,7 @@ from sqlmodel import (
select, select,
) )
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from analysis import analysis_router
app = FastAPI(title="cutt") app = FastAPI(title="cutt")
@ -81,4 +82,5 @@ def submit_chemistry(chemistry: Chemistry):
app.include_router(player_router) app.include_router(player_router)
app.include_router(team_router) app.include_router(team_router)
app.include_router(analysis_router)
app.mount("/", StaticFiles(directory="dist", html=True), name="site") app.mount("/", StaticFiles(directory="dist", html=True), name="site")

View File

@ -27,8 +27,14 @@
"eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-hooks": "^5.0.0",
"eslint-plugin-react-refresh": "^0.4.16", "eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0", "globals": "^15.14.0",
"react-router-dom": "^7.1.5",
"typescript": "~5.6.2", "typescript": "~5.6.2",
"typescript-eslint": "^8.18.2", "typescript-eslint": "^8.18.2",
"vite": "^6.0.5" "vite": "^6.0.5"
},
"prettier": {
"trailingComma": "es5",
"tabWidth": 2,
"semi": true
} }
} }

View File

@ -45,6 +45,30 @@ h3 {
padding: 8px 16px; padding: 8px 16px;
} }
.stack {
display: flex;
button,
img {
padding: 0 1em;
margin: 3px auto;
}
}
.column {
flex-direction: column;
}
#control-panel {
display: flex;
flex-direction: column;
input {
margin: auto
}
}
.container { .container {
display: flex; display: flex;
flex-wrap: nowrap; flex-wrap: nowrap;
@ -61,10 +85,12 @@ h3 {
.box { .box {
position: relative; position: relative;
flex: 1; flex: 1;
&.one { &.one {
max-width: min(96%, 768px); max-width: min(96%, 768px);
margin: 4px auto; margin: 4px auto;
} }
padding: 4px; padding: 4px;
margin: 4px 0.5%; margin: 4px 0.5%;
border-style: solid; border-style: solid;
@ -82,6 +108,7 @@ h3 {
max-width: 240px; max-width: 240px;
min-width: 100px; min-width: 100px;
margin: 4px auto; margin: 4px auto;
.item { .item {
font-weight: bold; font-weight: bold;
border-style: solid; border-style: solid;
@ -115,26 +142,26 @@ button {
.submit_text { .submit_text {
display: none; display: none;
} }
.submit { .submit {
position: fixed; position: fixed;
right: 16px; right: 16px;
bottom: 16px; bottom: 16px;
padding: 0px; padding: 0.4em;
background-color: unset; border-radius: 1em;
background-color: rgba(0, 0, 0, 0.6);
font-size: xx-large; font-size: xx-large;
margin-bottom: 20px; margin-bottom: 16px;
margin-right: 20px; margin-right: 16px;
} }
} }
::backdrop { ::backdrop {
background-image: linear-gradient( background-image: linear-gradient(45deg,
45deg, magenta,
magenta, rebeccapurple,
rebeccapurple, dodgerblue,
dodgerblue, green);
green
);
opacity: 0.75; opacity: 0.75;
} }
@ -152,6 +179,7 @@ button {
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
opacity: 50%; opacity: 50%;
&:hover { &:hover {
opacity: 75%; opacity: 75%;
} }
@ -178,10 +206,12 @@ button {
text-align: center; text-align: center;
height: 140px; height: 140px;
margin-bottom: 20px; margin-bottom: 20px;
img { img {
display: block; display: block;
margin: auto; margin: auto;
} }
h3 { h3 {
position: absolute; position: absolute;
font-size: medium; font-size: medium;
@ -206,6 +236,7 @@ button {
border: 4px solid black; border: 4px solid black;
overflow: hidden; overflow: hidden;
} }
.loader::after { .loader::after {
content: ""; content: "";
width: 32%; width: 32%;
@ -223,6 +254,7 @@ button {
left: 0; left: 0;
transform: translateX(-100%); transform: translateX(-100%);
} }
100% { 100% {
left: 100%; left: 100%;
transform: translateX(0%); transform: translateX(0%);

View File

@ -1,31 +1,27 @@
import Analysis from "./Analysis";
import { baseUrl } from "./api"; import { baseUrl } from "./api";
import "./App.css"; import "./App.css";
import Footer from "./Footer";
import Header from "./Header";
import Rankings from "./Rankings"; import Rankings from "./Rankings";
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() { function App() {
//const [data, setData] = useState({ nodes: [], links: [] } as SociogramData);
//async function loadData() {
// await fetch(`${baseUrl}analysis/json`, { method: "GET" }).then(resp => resp.json() as unknown as SociogramData).then(json => { setData(json) })
//}
//useEffect(() => { loadData() }, [])
//
return ( return (
<> <BrowserRouter>
<div className="logo"> <Header />
<a href={baseUrl}> <Routes>
<img alt="logo" height="66%" src="logo.svg" /> <Route index element={<Rankings />} />
<h3 className="centered">cutt</h3> <Route path="analysis" element={<Analysis />} />
</a> </Routes>
<span className="grey">cool ultimate team tool</span> <Footer />
</div> </BrowserRouter>
<Rankings />
<footer>
<p className="grey">
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>
</>
); );
} }
export default App; export default App;

View File

@ -136,8 +136,7 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
<h2>😬</h2> <h2>😬</h2>
{playersLeft.length < 1 && ( {playersLeft.length < 1 && (
<span className="grey hint"> <span className="grey hint">
drag people here that you'd rather not play with from worst to ... drag people here that you'd rather not play with
ok
</span> </span>
)} )}
<PlayerList <PlayerList
@ -145,7 +144,6 @@ export function Chemistry({ user, players }: PlayerInfoProps) {
setList={setPlayersLeft} setList={setPlayersLeft}
group={"shared"} group={"shared"}
className="dragbox" className="dragbox"
orderedList
/> />
</div> </div>
<div className="box three"> <div className="box three">