17 Commits

16 changed files with 670 additions and 51 deletions

View File

@@ -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,58 @@ 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},
}
)
for p in c.hate:
edges.append(
{
"id": f"{c.user}-x>{p}",
"source": c.user,
"target": p,
"size": 0.3,
"data": {"relation": 0},
"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 +194,31 @@ 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}"}
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:

2
db.py
View File

@@ -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

View File

@@ -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",

View File

@@ -9,6 +9,7 @@ from db import engine, User
from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm from fastapi.security import OAuth2PasswordBearer, OAuth2PasswordRequestForm
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):
@@ -47,10 +48,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):

View File

@@ -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>({

View File

@@ -23,6 +23,87 @@ footer {
font-size: x-small; font-size: x-small;
} }
/*=========Network Controls=========*/
.controls {
z-index: 9;
position: absolute;
width: 240px;
right: 24px;
top: 1vh;
padding: 16px;
.control {
display: flex;
flex-direction: row;
margin: 4px 2px;
background-color: aliceblue;
* {
margin: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 68px;
height: 42px;
}
/* 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: 26px;
width: 26px;
left: 4px;
bottom: 4px;
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(26px);
-ms-transform: translateX(26px);
transform: translateX(26px);
}
.grey { .grey {
color: #444; color: #444;
@@ -168,13 +249,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 +333,8 @@ button,
font-size: 150%; font-size: 150%;
} }
/*======LOGO=======*/
.logo { .logo {
position: relative; position: relative;
text-align: center; text-align: center;
@@ -268,6 +362,15 @@ button,
} }
} }
.networkroute {
z-index: 10;
position: absolute;
top: 24px;
left: 48px;
}
/*======SPINNER=======*/
.loader { .loader {
display: block; display: block;
position: relative; position: relative;

View File

@@ -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
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,23 @@
import { Link } from "react-router"; import { Link } from "react-router";
export default function Footer() { export default function Footer() {
return <footer> return <footer>
<div className="navbar"> <div className="navbar">
<Link to="/" ><span>Form</span></Link> <Link to="/" ><span>Form</span></Link>
<span>|</span> <span>|</span>
<Link to="/analysis" ><span>Trainer Analysis</span></Link> <Link to="/network" ><span>Trainer Analysis</span></Link>
</div> <span>|</span>
<p className="grey extra-margin"> <Link to="/mvp" ><span>MVP</span></Link>
something not working? </div>
<br /> <p className="grey extra-margin">
message <a href="https://t.me/x0124816">me</a>. something not working?
<br /> <br />
or fix it here:{" "} message <a href="https://t.me/x0124816">me</a>.
<a href="https://git.0124816.xyz/julius/cutt" key="gitea"> <br />
<img src="gitea.svg" alt="gitea" height="16" /> or fix it here:{" "}
</a> <a href="https://git.0124816.xyz/julius/cutt" key="gitea">
</p> <img src="gitea.svg" alt="gitea" height="16" />
</footer> </a>
</p>
</footer>
} }

View File

@@ -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
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;

151
src/Network.tsx Normal file
View File

@@ -0,0 +1,151 @@
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: string[];
}
const useCustomSelection = (props: CustomSelectionProps): SelectionResult => {
var result = useSelection(props);
result.actives = result.actives.filter((s) => !props.ignore.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 logo = document.getElementById("logo")
if (logo) {
logo.className = "logo networkroute";
}
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)
//graphRef.current?.fitNodesInView();
//graphRef.current?.centerGraph();
//graphRef.current?.resetControls();
}
function showLabel() {
switch (likes) {
case 0: return "dislike";
case 1: return "both";
case 2: return "like";
}
}
const { selections, actives, onNodeClick, onCanvasClick } = useCustomSelection({
ref: graphRef,
nodes: data.nodes,
edges: data.edges.filter((edge) => edge.data.relation === likes),
ignore: data.edges.map((edge) => { return (likes === 1 && edge.data.relation !== 2) ? edge.id : "" }),
pathSelectionType: 'out',
type: 'multiModifier'
});
return (
<div style={{ position: 'absolute', top: 0, bottom: 0, left: 0, right: 0 }}>
<div className="controls" >
<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}
/>
}
</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'
}
}
};

72
src/RaceChart.tsx Normal file
View File

@@ -0,0 +1,72 @@
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);
// 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;
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) => (
<text
key={index}
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={`${barHeight - 1.5 * gap}px`}
fill="aliceblue"
stroke='#36c'
strokeWidth={1.6}
fontWeight={"bold"}
paintOrder={"stroke fill"}
>{player.name}</text>
))}
</svg>
)
}
export default RaceChart;

View File

@@ -20,13 +20,16 @@ export default async function api(path: string, data: any): Promise<any> {
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: { {
"Authorization": `Bearer ${token()} `, method: method,
'Content-Type': 'application/json' headers: {
}, "Authorization": `Bearer ${token()} `,
body: JSON.stringify(data), 'Content-Type': 'application/json'
}); },
...(data && { body: JSON.stringify(data) })
}
);
let resp: Response; let resp: Response;
try { try {
resp = await fetch(req); resp = await fetch(req);

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