feat: working WebGL graph with selection/highlighting

This commit is contained in:
julius 2025-02-23 16:50:34 +01:00
parent 13bb965b28
commit 47fd9bd859
Signed by: julius
GPG Key ID: C80A63E6A5FD7092
5 changed files with 128 additions and 39 deletions

View File

@ -67,25 +67,32 @@ 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, **{"inDegree": in_degrees[node["id"]]}) for node in nodes]
return JSONResponse({"nodes": nodes, "edges": edges})

View File

@ -23,28 +23,38 @@ footer {
font-size: x-small;
}
/*=========Network Controls=========*/
.controls {
z-index: 9;
position: absolute;
top: 24;
left: 24;
width: 240px;
right: 24px;
top: 1vh;
padding: 16px;
.control {
display: flex;
flex-direction: row;
margin: 4px 2px;
background-color: aliceblue;
* {
margin: 0 4px;
margin: 4px;
}
}
#three-slider {
display: flex;
flex-direction: row;
}
}
/* The switch - the box around the slider */
.switch {
position: relative;
width: 68px;
height: 34px;
height: 42px;
}
/* Hide default HTML checkbox */
@ -239,13 +249,19 @@ button,
#control-panel {
grid-template-columns: repeat(2, 1fr);
}
}
@media only screen and (max-width: 768px) {
#control-panel {
grid-template-columns: 1fr;
}
.networkroute {
display: none;
}
.submit_text {
display: none;
}
@ -312,6 +328,8 @@ button,
font-size: 150%;
}
/*======LOGO=======*/
.logo {
position: relative;
text-align: center;
@ -339,6 +357,15 @@ button,
}
}
.networkroute {
z-index: 10;
position: absolute;
top: 24px;
left: 48px;
}
/*======SPINNER=======*/
.loader {
display: block;
position: relative;

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>

View File

@ -1,17 +1,31 @@
import { ChangeEvent, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { apiAuth } from "./api";
import { GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, recommendLayout, 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 logo = document.getElementById("logo")
if (logo) {
logo.className = "logo networkroute";
}
async function loadData() {
setLoading(true);
@ -24,12 +38,6 @@ export const GraphComponent = () => {
const graphRef = useRef<GraphCanvasRef | null>(null);
const { selections, actives, onNodeClick, onCanvasClick } = useSelection({
ref: graphRef,
nodes: data.nodes,
edges: data.edges,
pathSelectionType: 'out',
});
function handleThreed() {
setThreed(!threed)
@ -38,9 +46,28 @@ export const GraphComponent = () => {
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">
@ -49,26 +76,56 @@ export const GraphComponent = () => {
</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>
{loading ? <span className="loader" /> :
<GraphCanvas
draggable
cameraMode={threed ? "rotate" : "pan"}
layoutType={threed ? "forceDirected3d" : "forceDirected2d"}
layoutOverrides={{
linkDistance: 200
nodeStrength: -200,
linkDistance: 100
}}
labelType="auto"
defaultNodeSize={1}
labelType="nodes"
sizingType="attribute"
sizingAttribute="inDegree"
ref={graphRef}
theme={customTheme}
nodes={data.nodes}
edges={data.edges}
edges={data.edges.filter((edge) => edge.data.relation === likes || likes === 1)}
selections={selections}
actives={actives}
onCanvasClick={onCanvasClick}
onNodeClick={onNodeClick}
/>
/>}
</div>
);
}

View File

@ -34,7 +34,7 @@ export const customTheme: Theme = {
activeFill: '#36C',
opacity: 1,
selectedOpacity: 1,
inactiveOpacity: 0.2,
inactiveOpacity: 0.333,
label: {
stroke: '#fff',
color: '#2A6475',