12 Commits

13 changed files with 621 additions and 48 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,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("/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:

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,96 @@ footer {
font-size: x-small; font-size: x-small;
} }
/*=========Network Controls=========*/
.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 +258,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 +342,8 @@ button,
font-size: 150%; font-size: 150%;
} }
/*======LOGO=======*/
.logo { .logo {
position: relative; position: relative;
text-align: center; text-align: center;
@@ -268,6 +371,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,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="/network" ><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,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,15 +1,35 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useMemo, 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";
}
async function loadData() { async function loadData() {
setLoading(true); setLoading(true);
@@ -22,25 +42,144 @@ export const GraphComponent = () => {
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 } = 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',
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={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}
/>
}
</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;

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