graph WiP

This commit is contained in:
Kaushik Narayan R 2024-12-30 00:56:24 -07:00
parent 6d044d34d5
commit ad25ce7f05
15 changed files with 239 additions and 34 deletions

17
package-lock.json generated
View File

@ -8,6 +8,7 @@
"name": "spotify-manager-web", "name": "spotify-manager-web",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",
@ -2356,6 +2357,22 @@
"postcss-selector-parser": "^6.0.10" "postcss-selector-parser": "^6.0.10"
} }
}, },
"node_modules/@dagrejs/dagre": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@dagrejs/dagre/-/dagre-1.1.4.tgz",
"integrity": "sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==",
"dependencies": {
"@dagrejs/graphlib": "2.2.4"
}
},
"node_modules/@dagrejs/graphlib": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/@dagrejs/graphlib/-/graphlib-2.2.4.tgz",
"integrity": "sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==",
"engines": {
"node": ">17.0.0"
}
},
"node_modules/@eslint-community/eslint-utils": { "node_modules/@eslint-community/eslint-utils": {
"version": "4.4.1", "version": "4.4.1",
"resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz",

View File

@ -3,6 +3,7 @@
"version": "0.1.0", "version": "0.1.0",
"private": true, "private": true,
"dependencies": { "dependencies": {
"@dagrejs/dagre": "^1.1.4",
"@testing-library/jest-dom": "^6.6.3", "@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.1.0", "@testing-library/react": "^16.1.0",
"@testing-library/user-event": "^14.5.2", "@testing-library/user-event": "^14.5.2",

View File

@ -105,23 +105,25 @@ function App() {
return ( return (
<WidthContext.Provider value={width}> <WidthContext.Provider value={width}>
<AuthContext.Provider value={auth}> <AuthContext.Provider value={auth}>
<div className={styles.app_wrapper}> <RefreshAuthContext.Provider value={refreshAuth}>
<BrowserRouter> <div className={styles.app_wrapper}>
<ScrollToTop /> <BrowserRouter>
<Navbar /> <ScrollToTop />
<div className={styles.page_wrapper}> <Navbar />
<AllRoutes /> <div className={styles.page_wrapper}>
</div> <AllRoutes />
</BrowserRouter> </div>
<ToastContainer </BrowserRouter>
id={"notif-container"} <ToastContainer
position={"bottom-center"} id={"notif-container"}
theme={"dark"} position={"bottom-center"}
stacked theme={"dark"}
newestOnTop stacked
draggable newestOnTop
/> draggable
</div> />
</div>
</RefreshAuthContext.Provider>
</AuthContext.Provider> </AuthContext.Provider>
</WidthContext.Provider> </WidthContext.Provider>
); );

View File

@ -2,7 +2,6 @@
min-height: 100vh; min-height: 100vh;
width: 100vw; width: 100vw;
display: flex; display: flex;
align-items: center;
justify-content: space-evenly; justify-content: space-evenly;
} }
@ -11,6 +10,5 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
width: calc(100%); width: 100%;
margin-right: 10vw;
} }

11
src/api/operations.js Normal file
View File

@ -0,0 +1,11 @@
import { axiosInstance } from "./axiosInstance";
import { opFetchGraphURL } from "./paths";
export const apiFetchGraph = async () => {
try {
const response = await axiosInstance.get(opFetchGraphURL);
return response;
} catch (error) {
return error.response;
}
}

View File

@ -5,3 +5,5 @@ export const authLogoutURL = backendDomain + "api/auth/logout"
export const authHealthCheckURL = "auth-health"; export const authHealthCheckURL = "auth-health";
export const authRefreshURL = "api/auth/refresh"; export const authRefreshURL = "api/auth/refresh";
export const opFetchGraphURL = "api/operations/fetch";

View File

View File

@ -0,0 +1,12 @@
import React from 'react'
import styles from "./Button.module.css";
const Button = ({ child }) => {
return (
<>
{child}
</>
)
}
export default Button;

View File

@ -4,10 +4,12 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: space-around; justify-content: space-around;
position: sticky;
top: 0;
left: 0;
height: 100vh; height: 100vh;
width: 10vw; width: 10vw;
position: sticky;
position: -webkit-sticky;
top: 0;
left: 0;
overflow: auto;
padding: var(--mb-3); padding: var(--mb-3);
} }

View File

@ -4,3 +4,14 @@
height: 100vh; height: 100vh;
color: black; color: black;
} }
.operations_wrapper {
display: flex;
background-color: var(--bgNav);
flex-direction: column;
align-items: center;
justify-content: flex-start;
height: 100vh;
width: 10vw;
padding: var(--mb-3);
}

View File

@ -1,4 +1,4 @@
import React, { useCallback } from 'react'; import React, { useCallback, useContext, useEffect } from 'react';
import { import {
ReactFlow, ReactFlow,
Controls, Controls,
@ -6,25 +6,64 @@ import {
useNodesState, useNodesState,
useEdgesState, useEdgesState,
addEdge, addEdge,
Panel,
} from '@xyflow/react'; } from '@xyflow/react';
import Dagre from '@dagrejs/dagre';
import '@xyflow/react/dist/style.css'; import '@xyflow/react/dist/style.css';
import styles from './Graph.module.css'; import styles from './Graph.module.css';
// const initialNodes = []; import { showErrorToastNotification, showInfoToastNotification, showSuccessToastNotification } from '../../components/ToastNotification';
const initialNodes = [
{ id: '1', position: { x: 0, y: 0 }, data: { label: '1' } }, import { apiFetchGraph } from '../../api/operations';
{ id: '2', position: { x: 0, y: 100 }, data: { label: '2' } },
{ id: '3', position: { x: 50, y: 50 }, data: { label: '3' } }, import { RefreshAuthContext } from "../../App";
];
// const initialEdges = []; const initialNodes = [];
const initialEdges = [ // const initialNodes = [
{ id: 'e1-2', source: '1', target: '2' } // { id: '1', position: { x: 0, y: 0 }, data: { label: '1' } },
]; // { id: '2', position: { x: 0, y: 100 }, data: { label: '2' } },
// { id: '3', position: { x: 50, y: 50 }, data: { label: '3' } },
// ];
const initialEdges = [];
// const initialEdges = [
// { id: 'e1-2', source: '1', target: '2' }
// ];
const nodeOffsets = {
connected: {
origin: {
x: 1000,
y: 0
},
scaling: {
x: 270,
y: 90
}
},
unconnected: {
origin: {
x: 0,
y: 0
},
scaling: {
x: 180,
y: 60
}
}
}
const edgeOptions = {
animated: true,
style: {
stroke: 'white',
},
};
const proOptions = { hideAttribution: true }; const proOptions = { hideAttribution: true };
const Graph = () => { const Graph = () => {
const refreshAuth = useContext(RefreshAuthContext);
const [playlistNodes, setNodes, onNodesChange] = useNodesState(initialNodes); const [playlistNodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [linkEdges, setEdges, onEdgesChange] = useEdgesState(initialEdges); const [linkEdges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
@ -32,11 +71,104 @@ const Graph = () => {
setEdges((eds) => addEdge(params, eds)); setEdges((eds) => addEdge(params, eds));
}, [setEdges]); }, [setEdges]);
const getLayoutedElements = (nodes, edges, options = { direction: "TB" }) => {
const g = new Dagre.graphlib.Graph()
g.setDefaultEdgeLabel(() => ({}));
g.setGraph({ rankdir: options.direction });
edges.forEach((edge) => g.setEdge(edge.source, edge.target));
// const connectedNodes = new Set(edges.flatMap(edge => [edge.source, edge.target]));
// const unconnectedNodes = nodes.filter(node => !connectedNodes.has(node.id));
nodes.forEach((node) => {
// if (connectedNodes.has(node.id)) {
g.setNode(node.id, {
...node,
width: node.measured?.width ?? 0,
height: node.measured?.height ?? 0,
})
// }
});
Dagre.layout(g);
return {
nodes: nodes.map((node) => {
const position = g.node(node.id);
// We are shifting the dagre node position (anchor=center center) to the top left
// so it matches the React Flow node anchor point (top left).
const x = position.x - (node.measured?.width ?? 0) / 2;
const y = position.y - (node.measured?.height ?? 0) / 2;
return { ...node, position: { x, y } };
}),
edges,
};
};
useEffect(() => {
const fetchGraph = async () => {
const resp = await apiFetchGraph();
if (resp === undefined) {
showErrorToastNotification("Please try again after sometime");
return;
}
if (resp.status === 200) {
// place playlist nodes
setNodes(resp.data.playlists.map((pl, idx) => {
return {
id: `${pl.playlistID}`,
position: {
x: nodeOffsets.unconnected.origin.x + Math.floor(idx / 5) * nodeOffsets.unconnected.scaling.x,
y: nodeOffsets.unconnected.origin.y + Math.floor(idx % 5) * nodeOffsets.unconnected.scaling.y,
},
data: {
label: pl.playlistName,
meta: {
name: pl.playlistName
}
}
}
}));
// connect links
setEdges(resp.data.links.map((link, idx) => {
return {
id: `${idx}`,
source: link.from,
target: link.to
}
}));
showInfoToastNotification("Graph updated.");
return;
}
if (resp.status >= 500) {
showErrorToastNotification(resp.data.message);
return;
}
if (resp.status === 401) {
await refreshAuth();
}
showErrorToastNotification(resp.data.message);
return;
}
fetchGraph();
}, []);
const arrangeLayout = (direction) => {
const layouted = getLayoutedElements(playlistNodes, linkEdges, { direction });
setNodes([...layouted.nodes]);
setEdges([...layouted.edges]);
}
return ( return (
<div className={styles.graph_wrapper}> <div className={styles.graph_wrapper}>
<ReactFlow <ReactFlow
nodes={playlistNodes} nodes={playlistNodes}
edges={linkEdges} edges={linkEdges}
defaultEdgeOptions={edgeOptions}
fitView fitView
proOptions={proOptions} proOptions={proOptions}
onNodesChange={onNodesChange} onNodesChange={onNodesChange}
@ -45,7 +177,14 @@ const Graph = () => {
> >
<Controls /> <Controls />
<Background variant='dots' gap={36} size={3} /> <Background variant='dots' gap={36} size={3} />
<Panel position="top-right">
<button onClick={() => arrangeLayout('TB')}>Arrange vertically</button>
<button onClick={() => arrangeLayout('LR')}>Arrange horizontally</button>
</Panel>
</ReactFlow> </ReactFlow>
<div className={styles.operations_wrapper}>
test
</div>
</div> </div>
) )
} }

View File

@ -3,6 +3,7 @@
flex-direction: column; flex-direction: column;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize); font-size: var(--headingFontSize);
} }

View File

@ -2,5 +2,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize); font-size: var(--headingFontSize);
} }

View File

@ -2,5 +2,6 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize); font-size: var(--headingFontSize);
} }

View File

@ -0,0 +1,7 @@
.pnf_wrapper {
display: flex;
align-items: center;
justify-content: center;
margin-right: 10vw;
font-size: var(--headingFontSize);
}