Compare commits
29 Commits
feat/secur
...
b7c8136b1e
Author | SHA1 | Date | |
---|---|---|---|
b7c8136b1e
|
|||
b8c4190072
|
|||
d61bea3c86
|
|||
70a4ece5bc
|
|||
406ea9ffdd
|
|||
104ec70695
|
|||
9d65c1d1df
|
|||
de79970987
|
|||
a52dae5605
|
|||
a46427c6b8
|
|||
fd323db6d0
|
|||
c2d94c0400
|
|||
f94c3402c2
|
|||
5c21cf1fc3
|
|||
5cd793b278
|
|||
de8688133f
|
|||
d6e5d0334c
|
|||
5fef47f692
|
|||
978aafc204
|
|||
47fd9bd859
|
|||
13bb965b28
|
|||
5405c3e12f
|
|||
1eab163e10
|
|||
7c054d6ba3
|
|||
4a46cd505d
|
|||
1fa91a7228
|
|||
8e91724462
|
|||
1a1b44743a
|
|||
827eceed2b |
93
analysis.py
93
analysis.py
@@ -6,8 +6,9 @@ from fastapi.responses import JSONResponse
|
|||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlmodel import Session, func, select
|
from sqlmodel import Session, func, select
|
||||||
from sqlmodel.sql.expression import SelectOfScalar
|
from sqlmodel.sql.expression import SelectOfScalar
|
||||||
from db import Chemistry, Player, engine
|
from db import Chemistry, MVPRanking, Player, engine
|
||||||
import networkx as nx
|
import networkx as nx
|
||||||
|
import numpy as np
|
||||||
import matplotlib
|
import matplotlib
|
||||||
|
|
||||||
matplotlib.use("agg")
|
matplotlib.use("agg")
|
||||||
@@ -18,16 +19,17 @@ analysis_router = APIRouter(prefix="/analysis")
|
|||||||
|
|
||||||
|
|
||||||
C = Chemistry
|
C = Chemistry
|
||||||
|
R = MVPRanking
|
||||||
P = Player
|
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 +46,62 @@ 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,
|
||||||
|
"origSize": max(1.0 - 0.1 * i, 0.3),
|
||||||
|
"origFill": "#bed4ff",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
for p in c.hate:
|
||||||
|
edges.append(
|
||||||
|
{
|
||||||
|
"id": f"{c.user}-x>{p}",
|
||||||
|
"source": c.user,
|
||||||
|
"target": p,
|
||||||
|
"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})
|
||||||
|
|
||||||
|
|
||||||
def sociogram_data(show: int | None = 2):
|
def sociogram_data(show: int | None = 2):
|
||||||
@@ -143,8 +198,36 @@ async def render_sociogram(params: Params):
|
|||||||
return {"image": encoded_image}
|
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("/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"])
|
||||||
|
analysis_router.add_api_route("/mvp", endpoint=mvp, methods=["GET"])
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
with Session(engine) as session:
|
with Session(engine) as session:
|
||||||
|
3
db.py
3
db.py
@@ -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
|
||||||
|
|
||||||
|
|
||||||
@@ -69,6 +69,7 @@ class User(SQLModel, table=True):
|
|||||||
disabled: bool | None = None
|
disabled: bool | None = None
|
||||||
hashed_password: str | None = None
|
hashed_password: str | None = None
|
||||||
player_id: int | None = Field(default=None, foreign_key="player.id")
|
player_id: int | None = Field(default=None, foreign_key="player.id")
|
||||||
|
scopes: str = ""
|
||||||
|
|
||||||
|
|
||||||
SQLModel.metadata.create_all(engine)
|
SQLModel.metadata.create_all(engine)
|
||||||
|
25
main.py
25
main.py
@@ -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 fastapi.staticfiles import StaticFiles
|
||||||
from db import Player, Team, Chemistry, MVPRanking, engine
|
from db import Player, Team, Chemistry, MVPRanking, engine
|
||||||
from sqlmodel import (
|
from sqlmodel import (
|
||||||
@@ -18,10 +18,8 @@ from security import (
|
|||||||
app = FastAPI(title="cutt")
|
app = FastAPI(title="cutt")
|
||||||
api_router = APIRouter(prefix="/api")
|
api_router = APIRouter(prefix="/api")
|
||||||
origins = [
|
origins = [
|
||||||
"*",
|
"https://cutt.0124816.xyz",
|
||||||
"http://localhost",
|
"http://localhost:5173",
|
||||||
"http://localhost:3000",
|
|
||||||
"http://localhost:8000",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
@@ -66,11 +64,21 @@ def list_teams():
|
|||||||
|
|
||||||
player_router = APIRouter(prefix="/player")
|
player_router = APIRouter(prefix="/player")
|
||||||
player_router.add_api_route("/list", endpoint=list_players, methods=["GET"])
|
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 = APIRouter(prefix="/team")
|
||||||
team_router.add_api_route("/list", endpoint=list_teams, methods=["GET"])
|
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)
|
@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(player_router)
|
||||||
api_router.include_router(team_router)
|
api_router.include_router(team_router)
|
||||||
api_router.include_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("/token", endpoint=login_for_access_token, methods=["POST"])
|
||||||
api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"])
|
api_router.add_api_route("/users/me/", endpoint=read_users_me, methods=["GET"])
|
||||||
|
11
package.json
11
package.json
@@ -10,17 +10,16 @@
|
|||||||
"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",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
|
55
security.py
55
security.py
@@ -1,14 +1,19 @@
|
|||||||
from datetime import timedelta, timezone, datetime
|
from datetime import timedelta, timezone, datetime
|
||||||
from typing import Annotated
|
from typing import Annotated
|
||||||
from fastapi import Depends, HTTPException, Response, status
|
from fastapi import Depends, HTTPException, Response, status
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, ValidationError
|
||||||
import jwt
|
import jwt
|
||||||
from jwt.exceptions import InvalidTokenError
|
from jwt.exceptions import InvalidTokenError
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
from db import engine, User
|
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 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):
|
||||||
@@ -29,12 +34,18 @@ class Token(BaseModel):
|
|||||||
|
|
||||||
class TokenData(BaseModel):
|
class TokenData(BaseModel):
|
||||||
username: str | None = None
|
username: str | None = None
|
||||||
|
scopes: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
pwd_context = CryptContext(schemes=["argon2"], deprecated="auto")
|
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):
|
def verify_password(plain_password, hashed_password):
|
||||||
@@ -47,10 +58,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):
|
||||||
@@ -73,23 +87,37 @@ def create_access_token(data: dict, expires_delta: timedelta | None = None):
|
|||||||
return encoded_jwt
|
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(
|
credentials_exception = HTTPException(
|
||||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||||
detail="Could not validate credentials",
|
detail="Could not validate credentials",
|
||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": authenticate_value},
|
||||||
)
|
)
|
||||||
try:
|
try:
|
||||||
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
|
payload = jwt.decode(token, config.secret_key, algorithms=["HS256"])
|
||||||
username: str = payload.get("sub")
|
username: str = payload.get("sub")
|
||||||
if username is None:
|
if username is None:
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
token_data = TokenData(username=username)
|
token_scopes = payload.get("scopes", [])
|
||||||
except InvalidTokenError:
|
token_data = TokenData(username=username, scopes=token_scopes)
|
||||||
|
except (InvalidTokenError, ValidationError):
|
||||||
raise credentials_exception
|
raise credentials_exception
|
||||||
user = get_user(username=token_data.username)
|
user = get_user(username=token_data.username)
|
||||||
if user is None:
|
if user is None:
|
||||||
raise credentials_exception
|
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
|
return user
|
||||||
|
|
||||||
|
|
||||||
@@ -112,8 +140,11 @@ async def login_for_access_token(
|
|||||||
headers={"WWW-Authenticate": "Bearer"},
|
headers={"WWW-Authenticate": "Bearer"},
|
||||||
)
|
)
|
||||||
access_token_expires = timedelta(minutes=config.access_token_expire_minutes)
|
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(
|
access_token = create_access_token(
|
||||||
data={"sub": user.username}, expires_delta=access_token_expires
|
data={"sub": user.username, "scopes": list(allowed_scopes & requested_scopes)},
|
||||||
|
expires_delta=access_token_expires,
|
||||||
)
|
)
|
||||||
response.set_cookie(
|
response.set_cookie(
|
||||||
"Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none"
|
"Authorization", value=f"Bearer {access_token}", httponly=True, samesite="none"
|
||||||
|
@@ -42,7 +42,7 @@ interface DeferredProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
let timeoutID: number | null = null;
|
let timeoutID: NodeJS.Timeout | null = null;
|
||||||
export default function Analysis() {
|
export default function Analysis() {
|
||||||
const [image, setImage] = useState("");
|
const [image, setImage] = useState("");
|
||||||
const [params, setParams] = useState<Params>({
|
const [params, setParams] = useState<Params>({
|
||||||
|
131
src/App.css
131
src/App.css
@@ -23,6 +23,115 @@ footer {
|
|||||||
font-size: x-small;
|
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 {
|
.grey {
|
||||||
color: #444;
|
color: #444;
|
||||||
@@ -168,13 +277,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 +361,8 @@ button,
|
|||||||
font-size: 150%;
|
font-size: 150%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*======LOGO=======*/
|
||||||
|
|
||||||
.logo {
|
.logo {
|
||||||
position: relative;
|
position: relative;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
@@ -268,6 +390,15 @@ button,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.networkroute {
|
||||||
|
z-index: 10;
|
||||||
|
position: absolute;
|
||||||
|
top: 24px;
|
||||||
|
left: 48px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*======SPINNER=======*/
|
||||||
|
|
||||||
.loader {
|
.loader {
|
||||||
display: block;
|
display: block;
|
||||||
position: relative;
|
position: relative;
|
||||||
|
22
src/App.tsx
22
src/App.tsx
@@ -5,24 +5,34 @@ 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";
|
||||||
|
import MVPChart from "./MVPChart";
|
||||||
|
|
||||||
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 />
|
||||||
</SessionProvider>
|
</SessionProvider>
|
||||||
} />
|
} />
|
||||||
|
|
||||||
|
<Route path="/mvp" element={
|
||||||
|
<SessionProvider>
|
||||||
|
<MVPChart />
|
||||||
|
</SessionProvider>
|
||||||
|
} />
|
||||||
|
|
||||||
</Routes>
|
</Routes>
|
||||||
<Footer />
|
<Footer />
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
95
src/BarChart.tsx
Normal file
95
src/BarChart.tsx
Normal 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;
|
@@ -1,21 +1,31 @@
|
|||||||
import { Link } from "react-router";
|
import { Link } from "react-router";
|
||||||
|
|
||||||
export default function Footer() {
|
export default function Footer() {
|
||||||
return <footer>
|
return (
|
||||||
<div className="navbar">
|
<footer>
|
||||||
<Link to="/" ><span>Form</span></Link>
|
<div className="navbar">
|
||||||
<span>|</span>
|
<a href="/">
|
||||||
<Link to="/analysis" ><span>Trainer Analysis</span></Link>
|
<span>Form</span>
|
||||||
</div>
|
</a>
|
||||||
<p className="grey extra-margin">
|
<span>|</span>
|
||||||
something not working?
|
<a href="/network">
|
||||||
<br />
|
<span>Trainer Analysis</span>
|
||||||
message <a href="https://t.me/x0124816">me</a>.
|
</a>
|
||||||
<br />
|
<span>|</span>
|
||||||
or fix it here:{" "}
|
<a href="/mvp">
|
||||||
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
|
<span>MVP</span>
|
||||||
<img src="gitea.svg" alt="gitea" height="16" />
|
</a>
|
||||||
</a>
|
</div>
|
||||||
</p>
|
<p className="grey extra-margin">
|
||||||
</footer>
|
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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
29
src/MVPChart.tsx
Normal file
29
src/MVPChart.tsx
Normal 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;
|
285
src/Network.tsx
Normal file
285
src/Network.tsx
Normal file
@@ -0,0 +1,285 @@
|
|||||||
|
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: (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);
|
||||||
|
|
||||||
|
const logo = document.getElementById("logo");
|
||||||
|
if (logo) {
|
||||||
|
logo.className = "logo networkroute";
|
||||||
|
}
|
||||||
|
const footer = document.getElementsByTagName("footer");
|
||||||
|
if (footer) {
|
||||||
|
(footer.item(0) as HTMLElement).className = "fixed-footer";
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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.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 (
|
||||||
|
<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
59
src/NetworkTheme.tsx
Normal 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'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
98
src/RaceChart.tsx
Normal file
98
src/RaceChart.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import { FC, useEffect, useState } from "react";
|
||||||
|
import { PlayerRanking } from "./types";
|
||||||
|
|
||||||
|
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 / 20);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<svg width={width} height={height}>
|
||||||
|
{players.map((player, index) => (
|
||||||
|
<rect
|
||||||
|
key={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>
|
||||||
|
<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;
|
64
src/api.ts
64
src/api.ts
@@ -18,14 +18,18 @@ export default async function api(path: string, data: any): Promise<any> {
|
|||||||
return response;
|
return response;
|
||||||
}
|
}
|
||||||
|
|
||||||
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: {
|
method: method,
|
||||||
"Authorization": `Bearer ${token()} `,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
Authorization: `Bearer ${token()} `,
|
||||||
|
"Content-Type": "application/json",
|
||||||
},
|
},
|
||||||
body: JSON.stringify(data),
|
...(data && { body: JSON.stringify(data) }),
|
||||||
});
|
});
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
@@ -36,25 +40,28 @@ export async function apiAuth(path: string, data: any, method: string = "GET"):
|
|||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
logout()
|
logout();
|
||||||
throw new Error('Unauthorized');
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp.json()
|
return resp.json();
|
||||||
}
|
}
|
||||||
|
|
||||||
export type User = {
|
export type User = {
|
||||||
username: string;
|
username: string;
|
||||||
fullName: string;
|
full_name: string;
|
||||||
}
|
email: string;
|
||||||
|
player_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
export async function currentUser(): Promise<User> {
|
export async function currentUser(): Promise<User> {
|
||||||
if (!token()) throw new Error("you have no access token")
|
if (!token()) throw new Error("you have no access token");
|
||||||
const req = new Request(`${baseUrl}api/users/me/`, {
|
const req = new Request(`${baseUrl}api/users/me/`, {
|
||||||
method: "GET", headers: {
|
method: "GET",
|
||||||
"Authorization": `Bearer ${token()} `,
|
headers: {
|
||||||
'Content-Type': 'application/json'
|
Authorization: `Bearer ${token()} `,
|
||||||
}
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
});
|
});
|
||||||
let resp: Response;
|
let resp: Response;
|
||||||
try {
|
try {
|
||||||
@@ -65,8 +72,8 @@ export async function currentUser(): Promise<User> {
|
|||||||
|
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
if (resp.status === 401) {
|
if (resp.status === 401) {
|
||||||
logout()
|
logout();
|
||||||
throw new Error('Unauthorized');
|
throw new Error("Unauthorized");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return resp.json() as Promise<User>;
|
return resp.json() as Promise<User>;
|
||||||
@@ -83,11 +90,20 @@ export type Token = {
|
|||||||
|
|
||||||
export const login = (req: LoginRequest) => {
|
export const login = (req: LoginRequest) => {
|
||||||
fetch(`${baseUrl}api/token`, {
|
fetch(`${baseUrl}api/token`, {
|
||||||
method: "POST", headers: {
|
method: "POST",
|
||||||
'Content-Type': 'application/x-www-form-urlencoded',
|
headers: {
|
||||||
}, body: new URLSearchParams(req).toString()
|
"Content-Type": "application/x-www-form-urlencoded",
|
||||||
}).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>
|
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>;
|
||||||
|
};
|
||||||
|
|
||||||
export const logout = () => localStorage.removeItem("access_token");
|
export const logout = () => localStorage.removeItem("access_token");
|
||||||
|
20
src/types.ts
Normal file
20
src/types.ts
Normal 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;
|
||||||
|
}
|
Reference in New Issue
Block a user