16 Commits

14 changed files with 792 additions and 54 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,6 +19,7 @@ analysis_router = APIRouter(prefix="/analysis")
C = Chemistry C = Chemistry
R = MVPRanking
P = Player P = Player
@@ -67,25 +69,38 @@ def graph_json():
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest) subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
) )
for c in session.exec(statement2): for c in session.exec(statement2):
for p in c.love: for i, p in enumerate(c.love):
edges.append( edges.append(
{ {
"id": f"{c.user}->{p}", "id": f"{c.user}->{p}",
"source": c.user, "source": c.user,
"target": p, "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: for p in c.hate:
edges.append( edges.append(
{ {
id: f"{c.user}-x>{p}", "id": f"{c.user}-x>{p}",
"source": c.user, "source": c.user,
"target": p, "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}) return JSONResponse({"nodes": nodes, "edges": edges})
@@ -183,9 +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("/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:

14
main.py
View File

@@ -66,11 +66,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)

View File

@@ -10,16 +10,16 @@
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
"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", "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/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

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

View File

@@ -6,6 +6,7 @@ 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 { GraphComponent } from "./Network";
import MVPChart from "./MVPChart";
function App() { function App() {
return ( return (
@@ -13,16 +14,25 @@ function App() {
<Header /> <Header />
<Routes> <Routes>
<Route index element={<Rankings />} /> <Route index element={<Rankings />} />
<Route path="/network" element={ <Route path="/network" element={
<SessionProvider> <SessionProvider>
<GraphComponent /> <GraphComponent />
</SessionProvider> </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,11 +1,20 @@
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> <a href="/">
<span>Form</span>
</a>
<span>|</span> <span>|</span>
<Link to="/network" ><span>Trainer Analysis</span></Link> <a href="/network">
<span>Trainer Analysis</span>
</a>
<span>|</span>
<a href="/mvp">
<span>MVP</span>
</a>
</div> </div>
<p className="grey extra-margin"> <p className="grey extra-margin">
something not working? something not working?
@@ -18,4 +27,5 @@ export default function Footer() {
</a> </a>
</p> </p>
</footer> </footer>
);
} }

View File

@@ -1,7 +1,5 @@
import { baseUrl } from "./api";
export default function Header() { export default function Header() {
return <div className="logo"> return <div className="logo" id="logo">
<a href={"/"}> <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>

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,285 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api"; 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 { interface NetworkData {
nodes: GraphNode[], nodes: GraphNode[];
edges: GraphEdge[], 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 = () => { export const GraphComponent = () => {
const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData); const [data, setData] = useState({ nodes: [], edges: [] } as NetworkData);
const [loading, setLoading] = useState(true); 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() { async function loadData() {
setLoading(true); setLoading(true);
await apiAuth("analysis/graph_json", null) 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); setLoading(false);
} }
useEffect(() => { loadData() }, []) useEffect(() => {
loadData();
}, []);
const graphRef = useRef<GraphCanvasRef | null>(null); 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, ref: graphRef,
nodes: data.nodes, nodes: data.nodes,
edges: data.edges, edges: data.edges.filter((edge) => edge.data.relation === likes),
pathSelectionType: 'out' ignore: data.edges.map((edge) => {
if (likes === 1 && edge.data.relation !== 2) return edge;
}),
pathSelectionType: "out",
pathHoverType: "in",
type: "multiModifier",
}); });
return ( 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 <GraphCanvas
draggable draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
nodeStrength: -200,
linkDistance: 100,
}}
labelType="nodes"
sizingType="attribute"
sizingAttribute={popularity ? "inDegree" : undefined}
ref={graphRef} ref={graphRef}
theme={customTheme}
nodes={data.nodes} nodes={data.nodes}
edges={data.edges} edges={data.edges.filter(
(edge) => edge.data.relation === likes || likes === 1
)}
selections={selections} selections={selections}
actives={actives} actives={actives}
onCanvasClick={onCanvasClick} onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick} 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'
}
}
};

95
src/RaceChart.tsx Normal file
View File

@@ -0,0 +1,95 @@
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;
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}
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={4}
fontWeight={"bold"}
paintOrder={"stroke fill"}
fontFamily="monospace"
>
{player.name}
</text>
<text
key={index}
x={
4 +
Math.max(...players.map((p, _) => p.name.length)) *
(barHeight - 1.5 * gap) *
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.75 * (barHeight - 1.5 * gap)}px`}
fill="aliceblue"
stroke="#36c"
fontWeight={"bold"}
strokeWidth={4}
paintOrder={"stroke fill"}
style={{ whiteSpace: "pre" }}
>
{`${player.rank} ± ${player.std} N = ${player.n}`}
</text>
</g>
))}
</svg>
);
};
export default RaceChart;

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