From 25bda2bc4d64288fdb511008586e5ec469fd747f Mon Sep 17 00:00:00 2001 From: julius Date: Mon, 10 Feb 2025 16:12:31 +0100 Subject: [PATCH] feat: simple analysis page with sociogram --- analysis.py | 144 +++++++++++++++++++++++++++++++++++++++++++++++ main.py | 2 + package.json | 6 ++ src/App.css | 54 ++++++++++++++---- src/App.tsx | 40 ++++++------- src/Rankings.tsx | 4 +- 6 files changed, 214 insertions(+), 36 deletions(-) create mode 100644 analysis.py diff --git a/analysis.py b/analysis.py new file mode 100644 index 0000000..2b21945 --- /dev/null +++ b/analysis.py @@ -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() diff --git a/main.py b/main.py index 100cf03..d952e9d 100644 --- a/main.py +++ b/main.py @@ -6,6 +6,7 @@ from sqlmodel import ( select, ) from fastapi.middleware.cors import CORSMiddleware +from analysis import analysis_router app = FastAPI(title="cutt") @@ -81,4 +82,5 @@ def submit_chemistry(chemistry: Chemistry): app.include_router(player_router) app.include_router(team_router) +app.include_router(analysis_router) app.mount("/", StaticFiles(directory="dist", html=True), name="site") diff --git a/package.json b/package.json index 2a97b39..9b9d86e 100644 --- a/package.json +++ b/package.json @@ -27,8 +27,14 @@ "eslint-plugin-react-hooks": "^5.0.0", "eslint-plugin-react-refresh": "^0.4.16", "globals": "^15.14.0", + "react-router-dom": "^7.1.5", "typescript": "~5.6.2", "typescript-eslint": "^8.18.2", "vite": "^6.0.5" + }, + "prettier": { + "trailingComma": "es5", + "tabWidth": 2, + "semi": true } } diff --git a/src/App.css b/src/App.css index bd1b7b1..bac03ab 100644 --- a/src/App.css +++ b/src/App.css @@ -45,6 +45,30 @@ h3 { 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 { display: flex; flex-wrap: nowrap; @@ -61,10 +85,12 @@ h3 { .box { position: relative; flex: 1; + &.one { max-width: min(96%, 768px); margin: 4px auto; } + padding: 4px; margin: 4px 0.5%; border-style: solid; @@ -82,6 +108,7 @@ h3 { max-width: 240px; min-width: 100px; margin: 4px auto; + .item { font-weight: bold; border-style: solid; @@ -115,26 +142,26 @@ button { .submit_text { display: none; } + .submit { position: fixed; right: 16px; bottom: 16px; - padding: 0px; - background-color: unset; + padding: 0.4em; + border-radius: 1em; + background-color: rgba(0, 0, 0, 0.6); font-size: xx-large; - margin-bottom: 20px; - margin-right: 20px; + margin-bottom: 16px; + margin-right: 16px; } } ::backdrop { - background-image: linear-gradient( - 45deg, - magenta, - rebeccapurple, - dodgerblue, - green - ); + background-image: linear-gradient(45deg, + magenta, + rebeccapurple, + dodgerblue, + green); opacity: 0.75; } @@ -152,6 +179,7 @@ button { padding-top: 4px; padding-bottom: 4px; opacity: 50%; + &:hover { opacity: 75%; } @@ -178,10 +206,12 @@ button { text-align: center; height: 140px; margin-bottom: 20px; + img { display: block; margin: auto; } + h3 { position: absolute; font-size: medium; @@ -206,6 +236,7 @@ button { border: 4px solid black; overflow: hidden; } + .loader::after { content: ""; width: 32%; @@ -223,6 +254,7 @@ button { left: 0; transform: translateX(-100%); } + 100% { left: 100%; transform: translateX(0%); diff --git a/src/App.tsx b/src/App.tsx index 00483f9..665aa55 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -1,31 +1,27 @@ +import Analysis from "./Analysis"; import { baseUrl } from "./api"; import "./App.css"; +import Footer from "./Footer"; +import Header from "./Header"; import Rankings from "./Rankings"; +import { BrowserRouter, Routes, Route } from "react-router-dom"; 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 ( - <> -
- - logo -

cutt

-
- cool ultimate team tool -
- -
-

- something not working? -
- message me. -
- or fix it here:{" "} - - gitea - -

-
- + +
+ + } /> + } /> + +