feat: simple analysis page with sociogram
This commit is contained in:
parent
8def52fbf2
commit
25bda2bc4d
144
analysis.py
Normal file
144
analysis.py
Normal 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()
|
2
main.py
2
main.py
@ -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")
|
||||||
|
@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
54
src/App.css
54
src/App.css
@ -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%);
|
||||||
|
40
src/App.tsx
40
src/App.tsx
@ -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;
|
||||||
|
@ -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">
|
||||||
|
Loading…
x
Reference in New Issue
Block a user