25 Commits

Author SHA1 Message Date
8b092fed51 feat: implement login auth for all
also show the username underneath the logo
2025-03-06 13:29:10 +01:00
99e80c8077 feat: clickable username with context menu 2025-03-06 09:28:55 +01:00
854bd03c40 feat: add unique key prop to g 2025-03-05 12:59:09 +01:00
bc6c2a4a98 fix: give the token all available scopes for user 2025-03-05 11:45:47 +01:00
b7c8136b1e feat: add correct keys to User interface 2025-03-05 09:57:03 +01:00
b8c4190072 feat: implement auth scopes 2025-03-05 09:56:04 +01:00
d61bea3c86 feat: adjust formatting slightly 2025-03-03 16:31:32 +01:00
70a4ece5bc feat: add numbers to RaceChart 2025-03-03 16:05:28 +01:00
406ea9ffdd feat: remove unnecessary CORS origins 2025-03-03 16:02:44 +01:00
104ec70695 feat: add footer back to Network page 2025-03-03 11:52:48 +01:00
9d65c1d1df feat: add sample size 2025-03-03 09:40:16 +01:00
de79970987 feat: add value ± standard deviation to RaceChart 2025-03-02 20:09:54 +01:00
a52dae5605 feat: require auth to add players/teams 2025-03-02 09:28:16 +01:00
a46427c6b8 fix: in case no edge size is set 2025-02-25 18:40:50 +01:00
fd323db6d0 feat: implement highlighting of mutuality 2025-02-25 18:36:58 +01:00
c2d94c0400 fix: don't show nodes that are target of ignored edges 2025-02-25 08:55:44 +01:00
f94c3402c2 fix: text stroke 2025-02-24 18:17:53 +01:00
5c21cf1fc3 feat: add vertical BarChart 2025-02-24 18:04:20 +01:00
5cd793b278 fix: re-add utility types 2025-02-24 18:00:09 +01:00
de8688133f feat: add MPV to nav 2025-02-24 17:56:38 +01:00
d6e5d0334c feat: add RaceChart 2025-02-24 17:53:01 +01:00
5fef47f692 feat: implement BarChart for MVP 2025-02-24 16:51:50 +01:00
978aafc204 feat: add popularity option 2025-02-23 17:10:18 +01:00
47fd9bd859 feat: working WebGL graph with selection/highlighting 2025-02-23 16:50:34 +01:00
13bb965b28 feat: add 3D toggle and adjust theme 2025-02-20 17:26:27 +01:00
20 changed files with 1133 additions and 202 deletions

View File

@@ -6,8 +6,9 @@ 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
from db import Chemistry, MVPRanking, Player, engine
import networkx as nx
import numpy as np
import matplotlib
matplotlib.use("agg")
@@ -18,6 +19,7 @@ analysis_router = APIRouter(prefix="/analysis")
C = Chemistry
R = MVPRanking
P = Player
@@ -67,25 +69,38 @@ def graph_json():
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
)
for c in session.exec(statement2):
for p in c.love:
for i, p in enumerate(c.love):
edges.append(
{
"id": f"{c.user}->{p}",
"source": c.user,
"target": p,
"relation": "likes",
"size": max(1.0 - 0.1 * i, 0.3),
"data": {
"relation": 2,
"origSize": max(1.0 - 0.1 * i, 0.3),
"origFill": "#bed4ff",
},
}
)
continue
for p in c.hate:
edges.append(
{
id: f"{c.user}-x>{p}",
"id": f"{c.user}-x>{p}",
"source": c.user,
"target": p,
"relation": "dislikes",
"size": 0.3,
"data": {"relation": 0, "origSize": 0.3, "origFill": "#ff7c7c"},
"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})
@@ -183,9 +198,36 @@ async def render_sociogram(params: Params):
return {"image": encoded_image}
def mvp():
ranks = dict()
with Session(engine) as session:
subquery = (
select(R.user, func.max(R.time).label("latest"))
.where(R.time > datetime(2025, 2, 8))
.group_by(R.user)
.subquery()
)
statement2 = select(R).join(
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
)
for r in session.exec(statement2):
for i, p in enumerate(r.mvps):
ranks[p] = ranks.get(p, []) + [i + 1]
return [
{
"name": p,
"rank": f"{np.mean(v):.02f}",
"std": f"{np.std(v):.02f}",
"n": len(v),
}
for p, v in ranks.items()
]
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("/mvp", endpoint=mvp, methods=["GET"])
if __name__ == "__main__":
with Session(engine) as session:

1
db.py
View File

@@ -69,6 +69,7 @@ class User(SQLModel, table=True):
disabled: bool | None = None
hashed_password: str | None = None
player_id: int | None = Field(default=None, foreign_key="player.id")
scopes: str = ""
SQLModel.metadata.create_all(engine)

25
main.py
View File

@@ -1,4 +1,4 @@
from fastapi import APIRouter, Depends, FastAPI, status
from fastapi import APIRouter, Depends, FastAPI, Security, status
from fastapi.staticfiles import StaticFiles
from db import Player, Team, Chemistry, MVPRanking, engine
from sqlmodel import (
@@ -18,10 +18,8 @@ from security import (
app = FastAPI(title="cutt")
api_router = APIRouter(prefix="/api")
origins = [
"*",
"http://localhost",
"http://localhost:3000",
"http://localhost:8000",
"https://cutt.0124816.xyz",
"http://localhost:5173",
]
app.add_middleware(
@@ -66,11 +64,21 @@ def list_teams():
player_router = APIRouter(prefix="/player")
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"])
player_router.add_api_route("/add", endpoint=add_player, methods=["POST"])
player_router.add_api_route(
"/add",
endpoint=add_player,
methods=["POST"],
dependencies=[Depends(get_current_active_user)],
)
team_router = APIRouter(prefix="/team")
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
team_router.add_api_route("/add", endpoint=add_team, methods=["POST"])
team_router.add_api_route(
"/add",
endpoint=add_team,
methods=["POST"],
dependencies=[Depends(get_current_active_user)],
)
@app.post("/mvps/", status_code=status.HTTP_200_OK)
@@ -98,7 +106,8 @@ class SPAStaticFiles(StaticFiles):
api_router.include_router(player_router)
api_router.include_router(team_router)
api_router.include_router(
analysis_router, dependencies=[Depends(get_current_active_user)]
analysis_router,
dependencies=[Security(get_current_active_user, scopes=["analysis"])],
)
api_router.add_api_route("/token", endpoint=login_for_access_token, methods=["POST"])
api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"])

View File

@@ -10,16 +10,16 @@
"preview": "vite preview"
},
"dependencies": {
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react": "18.3.1",
"react-dom": "18.3.1",
"react-sortablejs": "^6.1.4",
"reagraph": "^4.21.2",
"sortablejs": "^1.15.6"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@types/react": "18.3.18",
"@types/react-dom": "18.3.5",
"@types/sortablejs": "^1.15.8",
"@vitejs/plugin-react": "^4.3.4",
"eslint": "^9.17.0",

View File

@@ -1,12 +1,16 @@
from datetime import timedelta, timezone, datetime
from typing import Annotated
from fastapi import Depends, HTTPException, Response, status
from pydantic import BaseModel
from pydantic import BaseModel, ValidationError
import jwt
from jwt.exceptions import InvalidTokenError
from sqlmodel import Session, select
from db import engine, User
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
from fastapi.security import (
OAuth2PasswordBearer,
OAuth2PasswordRequestForm,
SecurityScopes,
)
from pydantic_settings import BaseSettings, SettingsConfigDict
from passlib.context import CryptContext
from sqlalchemy.exc import OperationalError
@@ -30,12 +34,18 @@ class Token(BaseModel):
class TokenData(BaseModel):
username: str | None = None
scopes: list[str] = []
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="api/token")
oauth2_scheme = OAuth2PasswordBearer(
tokenUrl="api/token",
scopes={
"analysis": "Access the results.",
},
)
def verify_password(plain_password, hashed_password):
@@ -77,23 +87,37 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
return encoded_jwt
async def get_current_user(token: Annotated[str, Depends(oauth2_scheme)]):
async def get_current_user(
security_scopes: SecurityScopes, token: Annotated[str, Depends(oauth2_scheme)]
):
if security_scopes.scopes:
authenticate_value = f'Bearer scope="{security_scopes.scope_str}"'
else:
authenticate_value = "Bearer"
credentials_exception = HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Could not validate credentials",
headers={"WWW-Authenticate": "Bearer"},
headers={"WWW-Authenticate": authenticate_value},
)
try:
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
username: str = payload.get("sub")
if username is None:
raise credentials_exception
token_data = TokenData(username=username)
except InvalidTokenError:
token_scopes = payload.get("scopes", [])
token_data = TokenData(username=username, scopes=token_scopes)
except (InvalidTokenError, ValidationError):
raise credentials_exception
user = get_user(username=token_data.username)
if user is None:
raise credentials_exception
for scope in security_scopes.scopes:
if scope not in token_data.scopes:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Not enough permissions",
headers={"WWW-Authenticate": authenticate_value},
)
return user
@@ -116,8 +140,11 @@ async def login_for_access_token(
headers={"WWW-Authenticate": "Bearer"},
)
access_token_expires = timedelta(minutes=config.access_token_expire_minutes)
allowed_scopes = set(user.scopes.split())
requested_scopes = set(form_data.scopes)
access_token = create_access_token(
data={"sub": user.username}, expires_delta=access_token_expires
data={"sub": user.username, "scopes": list(allowed_scopes)},
expires_delta=access_token_expires,
)
response.set_cookie(
"Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none"

View File

@@ -42,7 +42,7 @@ interface DeferredProps {
}
let timeoutID: number | null = null;
let timeoutID: NodeJS.Timeout | null = null;
export default function Analysis() {
const [image, setImage] = useState("");
const [params, setParams] = useState<Params>({

View File

@@ -23,6 +23,114 @@ 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;
top: 1vh;
right: 0px;
padding: 8px;
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
.control {
display: flex;
flex-direction: row;
flex-wrap: wrap;
max-width: 240px;
margin: 0px;
background-color: #F0F8FFdd;
.slider,
span {
padding-left: 4px;
padding-right: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
margin: auto;
justify-content: center;
align-items: center;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 48px;
height: 24px;
}
/* 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: 18px;
width: 18px;
left: 3px;
bottom: 3px;
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(24px);
-ms-transform: translateX(24px);
transform: translateX(24px);
}
.grey {
color: #444;
@@ -168,13 +276,24 @@ button,
#control-panel {
grid-template-columns: repeat(2, 1fr);
}
.control {
font-size: 80%;
margin: 0px;
}
}
@media only screen and (max-width: 768px) {
#control-panel {
grid-template-columns: 1fr;
}
.networkroute {
display: none;
}
.submit_text {
display: none;
}
@@ -241,11 +360,17 @@ button,
font-size: 150%;
}
/*======LOGO=======*/
.logo {
position: relative;
text-align: center;
height: 140px;
margin-bottom: 20px;
span {
display: block;
margin: 2px;
}
img {
display: block;
@@ -268,6 +393,22 @@ button,
}
}
.avatar {
background-color: lightsteelblue;
padding: 2px 8px;
width: fit-content;
margin: auto;
}
.networkroute {
z-index: 3;
position: absolute;
top: 24px;
left: 48px;
}
/*======SPINNER=======*/
.loader {
display: block;
position: relative;

View File

@@ -6,25 +6,22 @@ import Rankings from "./Rankings";
import { BrowserRouter, Routes, Route } from "react-router";
import { SessionProvider } from "./Session";
import { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
import Avatar from "./Avatar";
function App() {
return (
<BrowserRouter>
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="/network" element={
<SessionProvider>
<GraphComponent />
</SessionProvider>
} />
<Route path="/analysis" element={
<SessionProvider>
<Analysis />
</SessionProvider>
} />
</Routes>
<Footer />
<SessionProvider>
<Header />
<Routes>
<Route index element={<Rankings />} />
<Route path="/network" element={<GraphComponent />} />
<Route path="/analysis" element={<Analysis />} />
<Route path="/mvp" element={<MVPChart />} />
</Routes>
<Footer />
</SessionProvider>
</BrowserRouter>
);
}

99
src/Avatar.tsx Normal file
View File

@@ -0,0 +1,99 @@
import { MouseEventHandler, useEffect, useState } from "react";
import { useSession } from "./Session";
import { logout } from "./api";
interface ContextMenuItem {
label: string;
onClick: () => void;
}
export default function Avatar() {
const { user, onLogout } = useSession();
const [contextMenu, setContextMenu] = useState<{
open: boolean;
mouseX: number;
mouseY: number;
}>({ open: false, mouseX: 0, mouseY: 0 });
const contextMenuItems: ContextMenuItem[] = [
{ label: "View Profile", onClick: () => console.log("View Profile") },
{ label: "Edit Profile", onClick: () => console.log("Edit Profile") },
{ label: "Logout", onClick: onLogout },
];
const handleMenuClick: MouseEventHandler<HTMLDivElement> = (event) => {
event.preventDefault();
setContextMenu({
open: !contextMenu.open,
mouseX: event.clientX + 4,
mouseY: event.clientY + 2,
});
};
useEffect(() => {
if (contextMenu.open) {
document.addEventListener("click", handleCloseContextMenuOutside);
}
return () => {
document.removeEventListener("click", handleCloseContextMenuOutside);
};
}, [contextMenu.open]);
const handleMenuClose = () => {
setContextMenu({ ...contextMenu, open: false });
};
const handleCloseContextMenuOutside: MouseEventHandler<Document> = (
event
) => {
if (
!event.target ||
(!(event.target as Element).closest(".context-menu") &&
!(event.target as Element).closest(".avatar"))
) {
handleMenuClose();
}
};
return (
<div
className="avatar"
onContextMenu={handleMenuClick}
style={{ display: user ? "block" : "none" }}
onClick={handleMenuClick}
>
{user?.username}
{contextMenu.open && (
<ul
className="context-menu"
style={{
zIndex: 3,
position: "absolute",
top: contextMenu.mouseY,
left: contextMenu.mouseX,
background: "white",
border: "1px solid #ddd",
padding: 0,
margin: 0,
listStyle: "none",
}}
>
{contextMenuItems.map((item, index) => (
<li
key={index}
style={{
padding: "10px",
borderBottom: "1px solid #ddd",
cursor: "pointer",
}}
onClick={() => {
item.onClick();
handleMenuClose();
}}
>
{item.label}
</li>
))}
</ul>
)}
</div>
);
}

95
src/BarChart.tsx Normal file
View File

@@ -0,0 +1,95 @@
import { FC } from 'react';
import { PlayerRanking } from './types';
interface BarChartProps {
players: PlayerRanking[];
width: number;
height: number;
std: boolean;
}
const BarChart: FC<BarChartProps> = ({ players, width, height, std }) => {
const padding = 24;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barWidth = (width - 2 * padding) / players.length;
return (
<svg width={width} height={height}>
{players.map((player, index) => (
<rect
key={index}
x={index * barWidth + padding}
y={height - (1 - player.rank / maxValue) * height}
width={barWidth - 8} // subtract 2 for some spacing between bars
height={(1 - player.rank / maxValue) * height}
fill="#69f"
/>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 - 4 + padding}
y={height - (1 - player.rank / maxValue) * height - 5}
textAnchor="middle"
//transform='rotate(-27)'
//style={{ transformOrigin: "center", transformBox: "fill-box" }}
fontSize="16px"
fill="#404040"
>
{player.name}
</text>
))}
{players.map((player, index) => (
<text
key={index}
x={index * barWidth + barWidth / 2 + padding - 4}
y={height - 8}
textAnchor="middle"
fontSize="12px"
fill="#404040"
>
{player.rank}
</text>
))}
{std && players.map((player, index) => (
<line
key={`error-${index}`}
x1={index * barWidth + barWidth / 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-top`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height - (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
{std && players.map((player, index) => (
<line
key={`cap-${index}-bottom`}
x1={index * barWidth + barWidth / 2 - 2 + padding}
y1={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
x2={index * barWidth + barWidth / 2 + 2 + padding}
y2={height - (1 - player.rank / maxValue) * height + (player.std / maxValue) * height}
stroke="#ff0000"
strokeWidth="1"
/>
))}
</svg>
);
};
export default BarChart;

View File

@@ -1,21 +1,33 @@
import { useLocation } from "react-router";
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>
</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>
const location = useLocation();
return (
<footer className={location.pathname === "/network" ? "fixed-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>
);
}

View File

@@ -1,11 +1,18 @@
import { baseUrl } from "./api";
import { Link, useLocation } from "react-router";
import Avatar from "./Avatar";
export default function Header() {
return <div className="logo">
<a href={"/"}>
<img alt="logo" height="66%" src="logo.svg" />
<h3 className="centered">cutt</h3>
</a>
<span className="grey">cool ultimate team tool</span>
</div>
const location = useLocation();
return (
<div className={location.pathname === "/network" ? "networkroute" : ""}>
<div className="logo">
<Link to="/">
<img alt="logo" height="66%" src="logo.svg" />
<h3 className="centered">cutt</h3>
</Link>
<span className="grey">cool ultimate team tool</span>
</div>
<Avatar />
</div>
);
}

View File

@@ -1,6 +1,6 @@
import { FormEvent, useContext, useState } from "react";
import { useNavigate } from "react-router";
import { currentUser, login, LoginRequest, User } from "./api";
import { useState } from "react";
import { currentUser, login, User } from "./api";
import Header from "./Header";
export interface LoginProps {
onLogin: (user: User) => void;
@@ -15,72 +15,58 @@ export const Login = ({ onLogin }: LoginProps) => {
async function doLogin() {
setLoading(true);
setError(null);
const timeout = new Promise((r) => setTimeout(r, 1500));
const timeout = new Promise((r) => setTimeout(r, 1000));
let user: User;
try {
login({ username, password });
await login({ username, password });
user = await currentUser();
} catch (e) {
await timeout;
setError(e);
setLoading(false);
return
return;
}
await timeout;
onLogin(user);
}
function handleClick() {
doLogin();
}
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
doLogin();
}
return (
<form onSubmit={handleSubmit}>
<div>
<input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} />
</div>
<div>
<input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} />
</div>
<button type="submit" value="login" style={{ fontSize: "small" }} onClick={handleClick} >login</button>
{loading && <span className="loader" />}
</form>
)
}
/*
export default function Login(props: { onLogin: (user: User) => void }) {
const { onLogin } = props;
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
async function handleLogin(e: FormEvent) {
e.preventDefault()
const timeout = new Promise((r) => setTimeout(r, 1500));
let user: User;
try {
login({ username, password })
user = await currentUser()
} catch (e) { await timeout; return }
await timeout;
onLogin(user);
}
return <div>
<form onSubmit={handleLogin}>
<div>
<input type="text" id="username" name="username" placeholder="username" required value={username} onChange={evt => setUsername(evt.target.value)} />
</div>
<div>
<input type="password" id="password" name="password" placeholder="password" minLength={8} value={password} required onChange={evt => setPassword(evt.target.value)} />
</div>
<input className="button" type="submit" value="login" onSubmit={handleLogin} />
</form>
</div>
} */
<>
<Header />
<form onSubmit={handleSubmit}>
<div>
<input
type="text"
id="username"
name="username"
placeholder="username"
required
value={username}
onChange={(evt) => setUsername(evt.target.value)}
/>
</div>
<div>
<input
type="password"
id="password"
name="password"
placeholder="password"
minLength={8}
value={password}
required
onChange={(evt) => setPassword(evt.target.value)}
/>
</div>
<button type="submit" value="login" style={{ fontSize: "small" }}>
login
</button>
{loading && <span className="loader" />}
</form>
</>
);
};

29
src/MVPChart.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { useEffect, useState } from "react";
import { apiAuth } from "./api";
import BarChart from "./BarChart";
import { PlayerRanking } from "./types";
import RaceChart from "./RaceChart";
const MVPChart = () => {
const [data, setData] = useState({} as PlayerRanking[]);
const [loading, setLoading] = useState(true);
const [showStd, setShowStd] = useState(false);
async function loadData() {
setLoading(true);
await apiAuth("analysis/mvp", null)
.then(json => json as Promise<PlayerRanking[]>).then(json => { setData(json.sort((a, b) => a.rank - b.rank)) })
setLoading(false);
}
useEffect(() => { loadData() }, [])
return (
<>
{loading ? <span className="loader" /> : <RaceChart std={showStd} players={data} />
}
</>)
}
export default MVPChart;

View File

@@ -1,46 +1,276 @@
import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api";
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, 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)[];
}
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;
};
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 [mutuality, setMutuality] = useState(false);
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);
const { selections, actives, onNodeClick, onCanvasClick } = useSelection({
function handleThreed() {
setThreed(!threed);
graphRef.current?.fitNodesInView();
graphRef.current?.centerGraph();
graphRef.current?.resetControls();
}
function handlePopularity() {
setPopularity(!popularity);
}
function handleMutuality() {
colorMatches(!mutuality);
setMutuality(!mutuality);
}
function showLabel() {
switch (likes) {
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 matches = useMemo(() => findMatches(data.edges), [])
function colorMatches(mutuality: boolean) {
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;
}
});
} 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;
}
});
}
setData({ nodes: data.nodes, edges: newEdges });
}
useEffect(() => {
if (mutuality) colorMatches(false);
colorMatches(mutuality);
}, [likes]);
const {
selections,
actives,
onNodeClick,
onCanvasClick,
onNodePointerOver,
onNodePointerOut,
} = useCustomSelection({
ref: graphRef,
nodes: data.nodes,
edges: data.edges,
pathSelectionType: 'out'
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",
pathHoverType: "in",
type: "multiModifier",
});
return (
<GraphCanvas
draggable
ref={graphRef}
nodes={data.nodes}
edges={data.edges}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
/>
<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} />
<span className="slider round"></span>
</div>
<span>mutuality</span>
</div>
<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}
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>
);
}
};

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'
}
}
};

99
src/RaceChart.tsx Normal file
View File

@@ -0,0 +1,99 @@
import { FC, useEffect, useState } from "react";
import { PlayerRanking } from "./types";
import { useSession } from "./Session";
interface RaceChartProps {
players: PlayerRanking[];
std: boolean;
}
const determineNiceWidth = (width: number) => {
const max = 1080;
if (width >= max) return max;
else if (width > 768) return width * 0.8;
else return width * 0.96;
};
const RaceChart: FC<RaceChartProps> = ({ players, std }) => {
// State to store window's width and height
const [width, setWidth] = useState(determineNiceWidth(window.innerWidth));
//const [height, setHeight] = useState(window.innerHeight);
const height = players.length * 40;
// Update state on resize
useEffect(() => {
const handleResize = () => {
setWidth(determineNiceWidth(window.innerWidth));
//setHeight(window.innerHeight);
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
};
}, []);
const padding = 24;
const gap = 8;
const maxValue = Math.max(...players.map((player) => player.rank)) + 1;
const barHeight = (height - 2 * padding) / players.length;
const fontSize = Math.min(barHeight - 1.5 * gap, width / 22);
return (
<svg width={width} height={height} id="RaceChartSVG">
{players.map((player, index) => (
<rect
key={String(index)}
x={0}
y={index * barHeight + padding}
width={(1 - player.rank / maxValue) * width}
height={barHeight - gap} // subtract 2 for some spacing between bars
fill="#36c"
/>
))}
{players.map((player, index) => (
<g key={"group" + index}>
<text
key={index + "_name"}
x={4}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={fontSize}
fill="aliceblue"
stroke="#36c"
strokeWidth={4}
fontWeight={"bold"}
paintOrder={"stroke fill"}
fontFamily="monospace"
style={{ whiteSpace: "pre" }}
>
{`${String(index + 1).padStart(2)}. ${player.name}`}
</text>
<text
key={index + "_value"}
x={
4 +
(4 + Math.max(...players.map((p, _) => p.name.length))) *
fontSize *
0.66
}
y={index * barHeight + barHeight / 2 + padding + gap / 2}
width={(1 - player.rank / maxValue) * width}
height={barHeight - 8} // subtract 2 for some spacing between bars
fontSize={0.8 * fontSize}
fill="aliceblue"
stroke="#36c"
fontWeight={"bold"}
fontFamily="monospace"
strokeWidth={4}
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${String(player.rank).padStart(5)} ± ${player.std} N = ${player.n}`}
</text>
</g>
))}
</svg>
);
};
export default RaceChart;

View File

@@ -1,36 +1,94 @@
import { createContext, ReactNode, useContext, useLayoutEffect, useState } from "react";
import { currentUser, User } from "./api";
import {
createContext,
ReactNode,
useContext,
useEffect,
useState,
} from "react";
import { currentUser, logout, User } from "./api";
import { Login } from "./Login";
import Header from "./Header";
export interface SessionProviderProps {
children: ReactNode;
}
const sessionContext = createContext<User | null>(null);
export interface Session {
user: User | null;
onLogout: () => void;
}
const sessionContext = createContext<Session>({
user: null,
onLogout: () => {},
});
export function SessionProvider(props: SessionProviderProps) {
const { children } = props;
const [user, setUser] = useState<User | null>(null);
const [err, setErr] = useState<unknown>(null);
const [loading, setLoading] = useState(false);
function loadUser() {
setLoading(true);
currentUser()
.then((user) => { setUser(user); setErr(null); })
.catch((err) => { setUser(null); setErr(err); });
.then((user) => {
setUser(user);
setErr(null);
})
.catch((err) => {
setUser(null);
setErr(err);
})
.finally(() => setLoading(false));
}
useLayoutEffect(() => { loadUser(); }, [err]);
useEffect(() => {
loadUser();
}, []);
function onLogin(user: User) {
setUser(user);
setErr(null);
}
async function onLogout() {
try {
logout();
setUser(null);
setErr({ message: "Logged out successfully" });
console.log("logged out.");
setLoading(true); // Set loading to true
loadUser();
} catch (e) {
console.error(e);
setErr(e); // Update the error state if logout fails
}
}
console.log("sanity", user);
let content: ReactNode;
if (!err && !user) content = <span className="loader" />;
else if (err) content = <Login onLogin={onLogin} />;
else content = <sessionContext.Provider value={user}>{children}</sessionContext.Provider>;
if (loading || (!err && !user))
content = (
<>
<Header />
<span className="loader" />
</>
);
else if (err) {
if ((err as any).message === "Logged out successfully") {
setTimeout(() => setErr(null), 1000);
content = <Login onLogin={onLogin} />;
} else {
content = <Login onLogin={onLogin} />;
}
} else
content = (
<sessionContext.Provider value={{ user, onLogout }}>
{children}
</sessionContext.Provider>
);
return content;
}

View File

@@ -18,46 +18,18 @@ export default async function api(path: string, data: any): Promise<any> {
return response;
}
export async function apiAuth(path: string, data: any, method: string = "GET"): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`,
{
method: method,
headers: {
"Authorization": `Bearer ${token()} `,
'Content-Type': 'application/json'
},
...(data && { body: JSON.stringify(data) })
}
);
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
if (!resp.ok) {
if (resp.status === 401) {
logout()
throw new Error('Unauthorized');
}
}
return resp.json()
}
export type User = {
username: string;
fullName: string;
}
export async function currentUser(): Promise<User> {
if (!token()) throw new Error("you have no access token")
const req = new Request(`${baseUrl}api/users/me/`, {
method: "GET", headers: {
"Authorization": `Bearer ${token()} `,
'Content-Type': 'application/json'
}
export async function apiAuth(
path: string,
data: any,
method: string = "GET"
): Promise<any> {
const req = new Request(`${baseUrl}api/${path}`, {
method: method,
headers: {
Authorization: `Bearer ${token()} `,
"Content-Type": "application/json",
},
...(data && { body: JSON.stringify(data) }),
});
let resp: Response;
try {
@@ -68,8 +40,40 @@ export async function currentUser(): Promise<User> {
if (!resp.ok) {
if (resp.status === 401) {
logout()
throw new Error('Unauthorized');
logout();
throw new Error("Unauthorized");
}
}
return resp.json();
}
export type User = {
username: string;
full_name: string;
email: string;
player_id: number;
};
export async function currentUser(): Promise<User> {
if (!token()) throw new Error("you have no access token");
const req = new Request(`${baseUrl}api/users/me/`, {
method: "GET",
headers: {
Authorization: `Bearer ${token()} `,
"Content-Type": "application/json",
},
});
let resp: Response;
try {
resp = await fetch(req);
} catch (e) {
throw new Error(`request failed: ${e}`);
}
if (!resp.ok) {
if (resp.status === 401) {
logout();
throw new Error("Unauthorized");
}
}
return resp.json() as Promise<User>;
@@ -84,13 +88,29 @@ export type Token = {
token_type: string;
};
export const login = (req: LoginRequest) => {
fetch(`${baseUrl}api/token`, {
method: "POST", headers: {
'Content-Type': 'application/x-www-form-urlencoded',
}, body: new URLSearchParams(req).toString()
}).then(resp => resp.json() as Promise<Token>).then(token => token ? localStorage.setItem("access_token", token.access_token) : console.log("token not acquired")).catch((e) => console.log("catch error " + e + " in login"));
return Promise<void>
}
// api.js
export const login = async (req: LoginRequest): Promise<void> => {
try {
const response = await fetch(`${baseUrl}api/token`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams(req).toString(),
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const token = (await response.json()) as Token;
if (token && token.access_token) {
localStorage.setItem("access_token", token.access_token);
} else {
console.log("Token not acquired");
}
} catch (e) {
console.error(e);
throw e; // rethrow the error so it can be caught by the caller
}
};
export const logout = () => localStorage.removeItem("access_token");

20
src/types.ts Normal file
View File

@@ -0,0 +1,20 @@
export interface Edge {
from: string;
to: string;
color: string;
relation: "likes" | "dislikes";
}
export interface Node {
id: string;
}
export default interface NetworkData {
nodes: Node[];
edges: Edge[];
}
export interface PlayerRanking {
name: string;
rank: number;
std: number;
n: number;
}