9 Commits

Author SHA1 Message Date
f94c3402c2 fix: text stroke 2025-02-24 18:17:53 +01:00
5c21cf1fc3 feat: add vertical BarChart 2025-02-24 18:04:20 +01:00
5cd793b278 fix: re-add utility types 2025-02-24 18:00:09 +01:00
de8688133f feat: add MPV to nav 2025-02-24 17:56:38 +01:00
d6e5d0334c feat: add RaceChart 2025-02-24 17:53:01 +01:00
5fef47f692 feat: implement BarChart for MVP 2025-02-24 16:51:50 +01:00
978aafc204 feat: add popularity option 2025-02-23 17:10:18 +01:00
47fd9bd859 feat: working WebGL graph with selection/highlighting 2025-02-23 16:50:34 +01:00
13bb965b28 feat: add 3D toggle and adjust theme 2025-02-20 17:26:27 +01:00
13 changed files with 573 additions and 47 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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