Compare commits
9 Commits
feat/inter
...
f94c3402c2
Author | SHA1 | Date | |
---|---|---|---|
f94c3402c2
|
|||
5c21cf1fc3
|
|||
5cd793b278
|
|||
de8688133f
|
|||
d6e5d0334c
|
|||
5fef47f692
|
|||
978aafc204
|
|||
47fd9bd859
|
|||
13bb965b28
|
45
analysis.py
45
analysis.py
@@ -6,8 +6,9 @@ from fastapi.responses import JSONResponse
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlmodel import Session, func, select
|
||||
from sqlmodel.sql.expression import SelectOfScalar
|
||||
from db import Chemistry, Player, engine
|
||||
from db import Chemistry, MVPRanking, Player, engine
|
||||
import networkx as nx
|
||||
import numpy as np
|
||||
import matplotlib
|
||||
|
||||
matplotlib.use("agg")
|
||||
@@ -18,6 +19,7 @@ analysis_router = APIRouter(prefix="/analysis")
|
||||
|
||||
|
||||
C = Chemistry
|
||||
R = MVPRanking
|
||||
P = Player
|
||||
|
||||
|
||||
@@ -67,25 +69,34 @@ def graph_json():
|
||||
subquery, (C.user == subquery.c.user) & (C.time == subquery.c.latest)
|
||||
)
|
||||
for c in session.exec(statement2):
|
||||
for p in c.love:
|
||||
for i, p in enumerate(c.love):
|
||||
edges.append(
|
||||
{
|
||||
"id": f"{c.user}->{p}",
|
||||
"source": c.user,
|
||||
"target": p,
|
||||
"relation": "likes",
|
||||
"size": max(1.0 - 0.1 * i, 0.3),
|
||||
"data": {"relation": 2},
|
||||
}
|
||||
)
|
||||
continue
|
||||
for p in c.hate:
|
||||
edges.append(
|
||||
{
|
||||
id: f"{c.user}-x>{p}",
|
||||
"id": f"{c.user}-x>{p}",
|
||||
"source": c.user,
|
||||
"target": p,
|
||||
"relation": "dislikes",
|
||||
"size": 0.3,
|
||||
"data": {"relation": 0},
|
||||
"fill": "#ff7c7c",
|
||||
}
|
||||
)
|
||||
|
||||
G = nx.DiGraph()
|
||||
G.add_weighted_edges_from([(e["source"], e["target"], e["size"]) for e in edges])
|
||||
in_degrees = G.in_degree(weight="weight")
|
||||
nodes = [
|
||||
dict(node, **{"data": {"inDegree": in_degrees[node["id"]]}}) for node in nodes
|
||||
]
|
||||
return JSONResponse({"nodes": nodes, "edges": edges})
|
||||
|
||||
|
||||
@@ -183,9 +194,31 @@ async def render_sociogram(params: Params):
|
||||
return {"image": encoded_image}
|
||||
|
||||
|
||||
def mvp():
|
||||
ranks = dict()
|
||||
with Session(engine) as session:
|
||||
subquery = (
|
||||
select(R.user, func.max(R.time).label("latest"))
|
||||
.where(R.time > datetime(2025, 2, 8))
|
||||
.group_by(R.user)
|
||||
.subquery()
|
||||
)
|
||||
statement2 = select(R).join(
|
||||
subquery, (R.user == subquery.c.user) & (R.time == subquery.c.latest)
|
||||
)
|
||||
for r in session.exec(statement2):
|
||||
for i, p in enumerate(r.mvps):
|
||||
ranks[p] = ranks.get(p, []) + [i + 1]
|
||||
return [
|
||||
{"name": p, "rank": f"{np.mean(v):.02f}", "std": f"{np.std(v):.02f}"}
|
||||
for p, v in ranks.items()
|
||||
]
|
||||
|
||||
|
||||
analysis_router.add_api_route("/json", endpoint=sociogram_json, methods=["GET"])
|
||||
analysis_router.add_api_route("/graph_json", endpoint=graph_json, methods=["GET"])
|
||||
analysis_router.add_api_route("/image", endpoint=render_sociogram, methods=["POST"])
|
||||
analysis_router.add_api_route("/mvp", endpoint=mvp, methods=["GET"])
|
||||
|
||||
if __name__ == "__main__":
|
||||
with Session(engine) as session:
|
||||
|
@@ -10,16 +10,16 @@
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"react": "18.3.1",
|
||||
"react-dom": "18.3.1",
|
||||
"react-sortablejs": "^6.1.4",
|
||||
"reagraph": "^4.21.2",
|
||||
"sortablejs": "^1.15.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"@types/react": "18.3.18",
|
||||
"@types/react-dom": "18.3.5",
|
||||
"@types/sortablejs": "^1.15.8",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"eslint": "^9.17.0",
|
||||
|
@@ -42,7 +42,7 @@ interface DeferredProps {
|
||||
}
|
||||
|
||||
|
||||
let timeoutID: number | null = null;
|
||||
let timeoutID: NodeJS.Timeout | null = null;
|
||||
export default function Analysis() {
|
||||
const [image, setImage] = useState("");
|
||||
const [params, setParams] = useState<Params>({
|
||||
|
103
src/App.css
103
src/App.css
@@ -23,6 +23,87 @@ footer {
|
||||
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 {
|
||||
color: #444;
|
||||
@@ -168,13 +249,24 @@ button,
|
||||
#control-panel {
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
}
|
||||
|
||||
.control {
|
||||
font-size: 80%;
|
||||
margin: 0px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
@media only screen and (max-width: 768px) {
|
||||
#control-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.networkroute {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.submit_text {
|
||||
display: none;
|
||||
}
|
||||
@@ -241,6 +333,8 @@ button,
|
||||
font-size: 150%;
|
||||
}
|
||||
|
||||
/*======LOGO=======*/
|
||||
|
||||
.logo {
|
||||
position: relative;
|
||||
text-align: center;
|
||||
@@ -268,6 +362,15 @@ button,
|
||||
}
|
||||
}
|
||||
|
||||
.networkroute {
|
||||
z-index: 10;
|
||||
position: absolute;
|
||||
top: 24px;
|
||||
left: 48px;
|
||||
}
|
||||
|
||||
/*======SPINNER=======*/
|
||||
|
||||
.loader {
|
||||
display: block;
|
||||
position: relative;
|
||||
|
10
src/App.tsx
10
src/App.tsx
@@ -6,6 +6,7 @@ import Rankings from "./Rankings";
|
||||
import { BrowserRouter, Routes, Route } from "react-router";
|
||||
import { SessionProvider } from "./Session";
|
||||
import { GraphComponent } from "./Network";
|
||||
import MVPChart from "./MVPChart";
|
||||
|
||||
function App() {
|
||||
return (
|
||||
@@ -13,16 +14,25 @@ function App() {
|
||||
<Header />
|
||||
<Routes>
|
||||
<Route index element={<Rankings />} />
|
||||
|
||||
<Route path="/network" element={
|
||||
<SessionProvider>
|
||||
<GraphComponent />
|
||||
</SessionProvider>
|
||||
} />
|
||||
|
||||
<Route path="/analysis" element={
|
||||
<SessionProvider>
|
||||
<Analysis />
|
||||
</SessionProvider>
|
||||
} />
|
||||
|
||||
<Route path="/mvp" element={
|
||||
<SessionProvider>
|
||||
<MVPChart />
|
||||
</SessionProvider>
|
||||
} />
|
||||
|
||||
</Routes>
|
||||
<Footer />
|
||||
</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,23 @@
|
||||
import { Link } from "react-router";
|
||||
|
||||
export default function Footer() {
|
||||
return <footer>
|
||||
<div className="navbar">
|
||||
<Link to="/" ><span>Form</span></Link>
|
||||
<span>|</span>
|
||||
<Link to="/network" ><span>Trainer Analysis</span></Link>
|
||||
</div>
|
||||
<p className="grey extra-margin">
|
||||
something not working?
|
||||
<br />
|
||||
message <a href="https://t.me/x0124816">me</a>.
|
||||
<br />
|
||||
or fix it here:{" "}
|
||||
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
|
||||
<img src="gitea.svg" alt="gitea" height="16" />
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
return <footer>
|
||||
<div className="navbar">
|
||||
<Link to="/" ><span>Form</span></Link>
|
||||
<span>|</span>
|
||||
<Link to="/network" ><span>Trainer Analysis</span></Link>
|
||||
<span>|</span>
|
||||
<Link to="/mvp" ><span>MVP</span></Link>
|
||||
</div>
|
||||
<p className="grey extra-margin">
|
||||
something not working?
|
||||
<br />
|
||||
message <a href="https://t.me/x0124816">me</a>.
|
||||
<br />
|
||||
or fix it here:{" "}
|
||||
<a href="https://git.0124816.xyz/julius/cutt" key="gitea">
|
||||
<img src="gitea.svg" alt="gitea" height="16" />
|
||||
</a>
|
||||
</p>
|
||||
</footer>
|
||||
}
|
||||
|
@@ -1,7 +1,5 @@
|
||||
import { baseUrl } from "./api";
|
||||
|
||||
export default function Header() {
|
||||
return <div className="logo">
|
||||
return <div className="logo" id="logo">
|
||||
<a href={"/"}>
|
||||
<img alt="logo" height="66%" src="logo.svg" />
|
||||
<h3 className="centered">cutt</h3>
|
||||
|
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;
|
137
src/Network.tsx
137
src/Network.tsx
@@ -1,15 +1,32 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { apiAuth } from "./api";
|
||||
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, useSelection } from "reagraph";
|
||||
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, SelectionProps, SelectionResult, useSelection } from "reagraph";
|
||||
import { customTheme } from "./NetworkTheme";
|
||||
|
||||
interface NetworkData {
|
||||
nodes: GraphNode[],
|
||||
edges: GraphEdge[],
|
||||
}
|
||||
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);
|
||||
@@ -22,25 +39,113 @@ export const GraphComponent = () => {
|
||||
|
||||
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)
|
||||
//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,
|
||||
pathSelectionType: 'out'
|
||||
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 (
|
||||
<GraphCanvas
|
||||
draggable
|
||||
ref={graphRef}
|
||||
nodes={data.nodes}
|
||||
edges={data.edges}
|
||||
selections={selections}
|
||||
actives={actives}
|
||||
onCanvasClick={onCanvasClick}
|
||||
onNodeClick={onNodeClick}
|
||||
/>
|
||||
);
|
||||
|
||||
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
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'
|
||||
}
|
||||
}
|
||||
};
|
72
src/RaceChart.tsx
Normal file
72
src/RaceChart.tsx
Normal 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;
|
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;
|
||||
}
|
||||
|
Reference in New Issue
Block a user