diff --git a/src/App.tsx b/src/App.tsx new file mode 100644 index 0000000..ab9caa1 --- /dev/null +++ b/src/App.tsx @@ -0,0 +1,53 @@ +import React, { useState } from 'react'; +import '../style/layout.css'; +import { GraphViewComponent } from './components/GaphView'; +import InfoBoxComponent from './components/InfoBox'; +import WorkspaceComponent from './components/Workspace'; +import { ISelectedNodeType } from './components/interfaces/InfoBoxInterfaces'; +import { IWorkspaceState } from './components/interfaces/WorkspaceInterfaces'; + +interface IAppProps { + fetchData: () => Promise; +} + +const App: React.FC = () => { + const [selectedNode, setSelectedNode] = useState(null); + + const [workspace, setWorkspace] = useState({ + model: null, + connectivity: null, + coupling: null, + noise: null, + integrationMethod: null + }); + + const addToWorkspace = (node: ISelectedNodeType) => { + setWorkspace(prevWorkspace => { + switch (node.type) { + case 'Neural Mass Model': + return { ...prevWorkspace, model: node }; + case 'TheVirtualBrain': + if (node.label.includes('Noise')) { + return { ...prevWorkspace, noise: node }; + } else { + return { ...prevWorkspace, connectivity: node }; + } + case 'Coupling': + return { ...prevWorkspace, coupling: node }; + case 'Integrator': + return { ...prevWorkspace, integrationMethod: node }; + default: + return prevWorkspace; // No changes if the type doesn't match + } + }); + }; + + return ( +
+ + + +
+ ); +}; +export default App; diff --git a/src/AppWidget.tsx b/src/AppWidget.tsx new file mode 100644 index 0000000..18da5ea --- /dev/null +++ b/src/AppWidget.tsx @@ -0,0 +1,16 @@ +import { ReactWidget } from '@jupyterlab/apputils'; +import React from 'react'; +import App from './App'; + +export class AppWidget extends ReactWidget { + fetchData: () => Promise; + constructor(fetchData: () => Promise) { + super(); + this.addClass('tvbo-AppWidget'); + this.fetchData = fetchData; + } + + render(): React.ReactElement { + return ; + } +} diff --git a/src/OntologyWidget.tsx b/src/OntologyWidget.tsx deleted file mode 100644 index e5977bb..0000000 --- a/src/OntologyWidget.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import React, { useEffect, useState } from 'react'; -import ForceGraph2D from 'react-force-graph-2d'; -import { ReactWidget } from '@jupyterlab/apputils'; - -interface IOntologyWidgetProps { - fetchData: () => Promise; -} - -export const OntologyWidgetComponent: React.FC = ({ - fetchData -}) => { - const [data, setData] = useState(null); - - useEffect(() => { - const fetchAndSetData = async () => { - try { - const result = await fetchData(); - setData(result); - } catch (error) { - console.error('Failed to fetch data:', error); - } - }; - - fetchAndSetData(); - }, [fetchData]); - - return ( -
- {data ? ( - { - const label = node.label; - const fontSize = 12 / globalScale; - ctx.font = `${fontSize}px Sans-Serif`; - ctx.fillStyle = 'black'; - ctx.textAlign = 'center'; - ctx.textBaseline = 'top'; - - const xCoord = node.x as number; - const yCoord = node.y as number; - ctx.fillText(label, xCoord, yCoord + 5); - }} - nodeCanvasObjectMode={() => 'after'} - linkDirectionalArrowLength={3.5} - linkDirectionalArrowRelPos={1} - /> - ) : ( -
Loading...
- )} -
- ); -}; - -export class OntologyWidget extends ReactWidget { - fetchData: () => Promise; - - constructor(fetchData: () => Promise) { - super(); - this.addClass('tvb-OntologyWidget'); - this.fetchData = fetchData; - } - - render(): React.ReactElement { - return ; - } -} diff --git a/src/components/GaphView.tsx b/src/components/GaphView.tsx new file mode 100644 index 0000000..debb2d2 --- /dev/null +++ b/src/components/GaphView.tsx @@ -0,0 +1,173 @@ +import React, { useEffect, useState } from 'react'; +import ForceGraph2D from 'react-force-graph-2d'; +import { fetchNodeByLabel, fetchNodeConnections } from '../handler'; +import { ISelectedNodeType } from './interfaces/InfoBoxInterfaces'; +import { ILinkType, INodeType } from './interfaces/GraphViewInterfaces'; +import { ITreeNode } from './interfaces/TreeViewInterfaces'; +import TreeViewComponent from './TreeView'; + +interface IGraphViewProps { + setSelectedNode: (node: ISelectedNodeType) => void; +} + +export const GraphViewComponent: React.FC = ({ + setSelectedNode +}) => { + const [data, setData] = useState<{ nodes: INodeType[]; links: ILinkType[]; }>({ nodes: [], links: [] }); + const [searchLabel, setSearchLabel] = useState(''); + const [treeData, setTreeData] = useState(null); + + useEffect(() => { + const fetchAndSetData = async (label?: string) => { + try { + // Fetch data + const result = await fetchNodeByLabel(label || ''); + setData(result); + } catch (error) { + console.error('Failed to fetch data:', error); + setData({ nodes: [], links: [] }); + } + }; + + fetchAndSetData(searchLabel); + }, [searchLabel]); + + const buildTree = (currentNode: INodeType): ITreeNode => { + const nodeMap = new Map(); + + // Initialize parents and children for all nodes in graph + data.nodes.forEach(node => { + nodeMap.set(node.id, { + id: node.id, + label: node.label, + type: node.type, + children: [], + parents: [] + }); + }); + console.log(nodeMap); + + const currentTreeNode = nodeMap.get(currentNode.id)!; + + // Get parents and children + data.links.forEach(link => { + console.log('Link: ', link); + const sourceNode = nodeMap.get(link.source); + const targetNode = nodeMap.get(link.target); + console.log('Source Node: ', sourceNode); + console.log('Target Node: ', targetNode); + + if (sourceNode && targetNode) { + if (link.target === currentNode.id) { + currentTreeNode.parents.push(sourceNode); + } else if (link.source === currentNode.id) { + currentTreeNode.children.push(targetNode); + } + + // Handle cases where a child node is also connected to the parents + if (currentTreeNode.parents.includes(targetNode) && currentTreeNode.children.includes(sourceNode)) { + sourceNode.parents.push(targetNode); + targetNode.children.push(sourceNode); + } + } + }); + + return currentTreeNode; + }; + + const handleNodeClick = async (node: INodeType) => { + setSelectedNode({ + label: node.label, + type: node.type, + definition: node.definition, + iri: node.iri, + childLinks: node.childLinks, + collapsed: false + }); + console.log('Node clicked'); + + // Build the tree view for the clicked node + const tree = buildTree(node); + setTreeData(tree); + + const connections = await fetchNodeConnections(node.label); + + const nodesById = Object.fromEntries( + data.nodes.map(node => [node.id, node]) + ); + + // link parent/children + data.nodes.forEach(n => { + n.collapsed = n.id !== node.id; + n.childLinks = []; + }); + + connections!.links.forEach(link => { + const sourceNode = nodesById[link.source]; + if (sourceNode) { + sourceNode.childLinks!.push(link); + } else { + console.error( + `Node with id ${link.source} does not exist in nodesById` + ); + } + }); + data.nodes = Object.values(nodesById); + }; + + // Handle search + const handleSearch = () => { + if (searchLabel.trim()) { + setSearchLabel(searchLabel.trim()); + } + }; + + // Handle Enter key press to trigger the search + const handleKeyPress = (event: React.KeyboardEvent) => { + if (event.key === 'Enter') { + handleSearch(); + } + }; + + return ( +
+ +
+ setSearchLabel(e.target.value)} + onKeyPress={handleKeyPress} + placeholder="Enter node label" + /> + +
+
+ {data ? ( + { + const label = node.label; + const fontSize = 12 / globalScale; + ctx.font = `${fontSize}px Sans-Serif`; + ctx.fillStyle = 'black'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + + const xCoord = node.x as number; + const yCoord = node.y as number; + ctx.fillText(label, xCoord, yCoord + 5); + }} + nodeCanvasObjectMode={() => 'after'} + linkDirectionalArrowLength={3.5} + linkDirectionalArrowRelPos={1} + /> + ) : ( +
Loading...
+ )} +
+
+ ); +}; diff --git a/src/components/InfoBox.tsx b/src/components/InfoBox.tsx new file mode 100644 index 0000000..5b08dbe --- /dev/null +++ b/src/components/InfoBox.tsx @@ -0,0 +1,62 @@ +import React from 'react'; +import { ISelectedNodeType } from './interfaces/InfoBoxInterfaces'; + +interface IInfoBoxProps { + selectedNode: { + label: string; + type: string; + definition: string; + iri: string; + } | null; + addToWorkspace: (node: ISelectedNodeType) => void; +} + +const InfoBoxComponent: React.FC = ({ + selectedNode, + addToWorkspace +}) => { + // Valid types for adding objects to workspace + const validTypes = [ + 'Neural Mass Model', + 'TheVirtualBrain', // TODO: change to actual type for connectivity, noise + 'Coupling', + 'Integrator' + ]; + + const isAddable = selectedNode && validTypes.includes(selectedNode.type); + + return ( +
+ {selectedNode ? ( +
+

Node Information

+

+ Name: {selectedNode.label} +

+

+ Type: {selectedNode.type} +

+

+ Definition: {selectedNode.definition} +

+

+ IRI:{' '} + + {selectedNode.iri} + +

+ +
+ ) : ( +

Select a node to see its details here

+ )} +
+ ); +}; + +export default InfoBoxComponent; diff --git a/src/components/SearchBar.tsx b/src/components/SearchBar.tsx new file mode 100644 index 0000000..569cfde --- /dev/null +++ b/src/components/SearchBar.tsx @@ -0,0 +1,7 @@ +import React from 'react'; + +const SeachBarComponent: React.FC = () => { + return
Search Bar
; +}; + +export default SeachBarComponent; diff --git a/src/components/TreeView.tsx b/src/components/TreeView.tsx new file mode 100644 index 0000000..4db0073 --- /dev/null +++ b/src/components/TreeView.tsx @@ -0,0 +1,40 @@ +import React from 'react'; +import { ITreeNode } from './interfaces/TreeViewInterfaces'; + +interface ITreeViewProps { + treeData: ITreeNode | null; +} + +const TreeViewComponent: React.FC = ({ treeData }) => { + const renderTree = (node: ITreeNode) => ( +
    + {node.parents.map(parent => ( +
  • + {parent.label} (Parent) + {parent.children.length > 0 && renderTree(parent)} +
  • + ))} +
  • + {node.label} (Current) + {node.children.length > 0 && ( +
      + {node.children.map(child => ( +
    • + {child.label} (Child) + {child.children.length > 0 && renderTree(child)} +
    • + ))} +
    + )} +
  • +
+ ); + return ( +
+

Tree View

+ {treeData ? renderTree(treeData) :

Please select a node first

} +
+ ); +}; + +export default TreeViewComponent; diff --git a/src/components/Workspace.tsx b/src/components/Workspace.tsx new file mode 100644 index 0000000..3b8864e --- /dev/null +++ b/src/components/Workspace.tsx @@ -0,0 +1,32 @@ +import React from 'react'; +import { IWorkspaceProps } from './interfaces/WorkspaceInterfaces'; + +const WorkspaceComponent: React.FC = ({ workspace }) => { + return ( +
+

Workspace

+
+

Model

+ {workspace.model ?
{workspace.model.label}
:
No model selected
} +
+
+

Connectivity

+ {workspace.connectivity ?
{workspace.connectivity.label}
:
No connectivity selected
} +
+
+

Coupling

+ {workspace.coupling ?
{workspace.coupling.label}
:
No coupling selected
} +
+
+

Noise

+ {workspace.noise ?
{workspace.noise.label}
:
No noise selected
} +
+
+

Integration Method

+ {workspace.integrationMethod ?
{workspace.integrationMethod.label}
:
No integration method selected
} +
+
+ ); +}; + +export default WorkspaceComponent; diff --git a/src/components/interfaces/GraphViewInterfaces.tsx b/src/components/interfaces/GraphViewInterfaces.tsx new file mode 100644 index 0000000..2cd5470 --- /dev/null +++ b/src/components/interfaces/GraphViewInterfaces.tsx @@ -0,0 +1,17 @@ +export interface INodeType { + id: number; + label: string; + type: string; + definition: string; + iri: string; + x?: number; + y?: number; + collapsed?: boolean; + childLinks?: ILinkType[]; +} + +export interface ILinkType { + source: number; + target: number; + type: string; +} diff --git a/src/components/interfaces/InfoBoxInterfaces.tsx b/src/components/interfaces/InfoBoxInterfaces.tsx new file mode 100644 index 0000000..4b9ef8d --- /dev/null +++ b/src/components/interfaces/InfoBoxInterfaces.tsx @@ -0,0 +1,10 @@ +import { ILinkType } from './GraphViewInterfaces'; + +export interface ISelectedNodeType { + label: string; + type: string; + definition: string; + iri: string; + childLinks?: ILinkType[], + collapsed?: boolean; +} diff --git a/src/components/interfaces/TreeViewInterfaces.tsx b/src/components/interfaces/TreeViewInterfaces.tsx new file mode 100644 index 0000000..aee1d90 --- /dev/null +++ b/src/components/interfaces/TreeViewInterfaces.tsx @@ -0,0 +1,7 @@ +export interface ITreeNode { + id: number; + label: string; + type: string; + children: ITreeNode[]; + parents: ITreeNode[]; +} diff --git a/src/components/interfaces/WorkspaceInterfaces.tsx b/src/components/interfaces/WorkspaceInterfaces.tsx new file mode 100644 index 0000000..3b5f773 --- /dev/null +++ b/src/components/interfaces/WorkspaceInterfaces.tsx @@ -0,0 +1,13 @@ +import { ISelectedNodeType } from './InfoBoxInterfaces'; + +export interface IWorkspaceProps { + workspace: IWorkspaceState; +} + +export interface IWorkspaceState { + model: ISelectedNodeType | null; + connectivity: ISelectedNodeType | null; + coupling: ISelectedNodeType | null; + noise: ISelectedNodeType | null; + integrationMethod: ISelectedNodeType | null; +} diff --git a/src/handler.ts b/src/handler.ts index 8b0dc6e..a29ccd8 100644 --- a/src/handler.ts +++ b/src/handler.ts @@ -1,6 +1,7 @@ import { URLExt } from '@jupyterlab/coreutils'; import { ServerConnection } from '@jupyterlab/services'; +import { ILinkType, INodeType } from './components/interfaces/GraphViewInterfaces'; /** * Call the API extension @@ -45,13 +46,21 @@ export async function requestAPI( return data; } -export async function fetchNodeByLabel(label: string): Promise { +export async function fetchNodeByLabel(label: string): Promise { try { const response = await requestAPI(`node?label=${label}`); - console.log('Response from handlers.ts: ', response); - console.log(typeof response); return response; } catch (error) { console.error(`Error fetching node data: ${error}`); } } + +export async function fetchNodeConnections(label: string): Promise<{ nodes: INodeType[]; links: ILinkType[] } | null> { + try { + const response = await requestAPI<{ nodes: INodeType[]; links: ILinkType[] }>(`node-connections?label=${label}`); + return response; + } catch (error) { + console.error(`Error fetching node data: ${error}`); + return null; + } +} diff --git a/src/index.ts b/src/index.ts index 59d9f99..2dea198 100644 --- a/src/index.ts +++ b/src/index.ts @@ -4,11 +4,11 @@ import { } from '@jupyterlab/application'; import { fetchNodeByLabel } from './handler'; -import { OntologyWidget } from './OntologyWidget'; import { ILauncher } from '@jupyterlab/launcher'; import { LabIcon } from '@jupyterlab/ui-components'; import ontologyIconSVG from '../style/tvbo_favicon.svg'; +import { AppWidget } from './AppWidget'; const ontologyIcon = new LabIcon({ name: 'custom:ontology-icon', @@ -32,9 +32,8 @@ const plugin: JupyterFrontEndPlugin = { label: 'Ontology Widget', icon: ontologyIcon, execute: () => { - const widget = new OntologyWidget(() => - fetchNodeByLabel('example-label') - ); + // const widget = new OntologyWidget(() => + const widget = new AppWidget(() => fetchNodeByLabel('JansenRit')); widget.id = 'tvb-ext-ontology-widget'; widget.title.label = 'Ontology Graph'; widget.title.closable = true; diff --git a/style/layout.css b/style/layout.css new file mode 100644 index 0000000..9ab88d2 --- /dev/null +++ b/style/layout.css @@ -0,0 +1,86 @@ +.layout { + display: grid; + grid-template: + 'tree-view tree-view info-box' auto + 'ontology-graph ontology-graph info-box' 1fr + 'ontology-graph ontology-graph workspace' 1fr / 1fr 1fr 1fr; + height: 90vh; + gap: 10px; + background-color: white; +} + +.search-bar { + grid-area: search-bar; + border: 1px solid #ddd; + padding: 10px; + overflow: auto; + background-color: whitesmoke; +} + +.tree-view { + grid-area: tree-view; + border: 1px solid #ddd; + padding: 10px; + overflow: auto; + background-color: whitesmoke; +} + +.ontology-graph { + grid-area: ontology-graph; + border: 1px solid #ddd; + padding: 10px; + overflow: auto; + background-color: whitesmoke; +} + +.info-box { + grid-area: info-box; + border: 1px solid #ddd; + padding: 10px; + overflow: auto; + background-color: whitesmoke; +} + +.workspace { + grid-area: workspace; + border: 1px solid #ddd; + padding: 10px; + overflow: auto; + background-color: whitesmoke; +} + +.search-bar { + display: flex; + margin-bottom: 10px; +} + +.search-bar input { + flex: 1; + padding: 5px; + font-size: 16px; + border: 1px solid #ccc; + border-radius: 4px; +} + +.search-bar button, +.info-box button{ + margin-left: 10px; + padding: 5px 10px; + font-size: 16px; + background-color: #007bff; + color: white; + border: none; + border-radius: 4px; + cursor: pointer; +} + +.search-bar button:hover, +.info-box button:hover{ + background-color: #0056b3; +} + +/* Disabled button style */ +.info-box button:disabled { + background-color: #ccc; + cursor: not-allowed; +} diff --git a/tsconfig.json b/tsconfig.json index 9897917..a1ef694 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,7 +17,7 @@ "rootDir": "src", "strict": true, "strictNullChecks": true, - "target": "ES2018" + "target": "es2019" }, - "include": ["src/*"] + "include": ["src/**/*", "custom.d.ts"] } diff --git a/tvb_ext_ontology/handlers.py b/tvb_ext_ontology/handlers.py index e2e6b9a..9a1b3b9 100644 --- a/tvb_ext_ontology/handlers.py +++ b/tvb_ext_ontology/handlers.py @@ -5,6 +5,8 @@ import tornado from tvbo.api.ontology_api import OntologyAPI +onto_api = OntologyAPI() + class RouteHandler(APIHandler): # The following decorator should be present on all verb methods (head, get, post, @@ -26,8 +28,31 @@ def get(self): self.finish(json.dumps({"error": "Missing 'label' parameter"})) return - onto_api = OntologyAPI() node_data = onto_api.get_node_by_label(label) + print(f'Links: {node_data["links"]}') + if not node_data: + self.set_status(404) + self.finish(json.dumps({"error": f"No data found for label: {label}"})) + return + + self.set_header("Content-Type", "application/json") + self.finish(json.dumps(node_data)) + + +class NodeConnectionsHandler(APIHandler): + @tornado.web.authenticated + def get(self): + label = self.get_argument('label', None) + if not label: + self.set_status(400) + self.finish(json.dumps({"error": "Missing 'label' parameter"})) + return + + onto_api.expand_node_relationships(label) + nodes = onto_api.nodes + links = onto_api.edges + node_data = {'nodes': nodes, 'links': links} + print(f'Connections links: {node_data["links"]}') if not node_data: self.set_status(404) self.finish(json.dumps({"error": f"No data found for label: {label}"})) @@ -43,9 +68,11 @@ def setup_handlers(web_app): base_url = web_app.settings["base_url"] route_pattern = url_path_join(base_url, "tvb-ext-ontology", "get-example") node_pattern = url_path_join(base_url, "tvb-ext-ontology", "node") + node_connections_pattern = url_path_join(base_url, "tvb-ext-ontology", "node-connections") handlers = [ (route_pattern, RouteHandler), - (node_pattern, NodeHandler) + (node_pattern, NodeHandler), + (node_connections_pattern, NodeConnectionsHandler) ] web_app.add_handlers(host_pattern, handlers)