diff --git a/.vscode/launch.json b/.vscode/launch.json index 99ff9e1f..dcd074d6 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -52,6 +52,26 @@ "request": "launch", "script": "${workspaceFolder}/rsconcept/RunServer.ps1", "args": ["-freshStart"] - } + }, + { + "name": "FE-Debug", + "type": "node", + "request": "launch", + "runtimeExecutable": "${workspaceFolder}/rsconcept/frontend/node_modules/.bin/jest", + "args": [ + "${fileBasenameNoExtension}", + "--runInBand", + "--watch", + "--coverage=false", + "--no-cache" + ], + "cwd": "${workspaceFolder}/rsconcept/frontend", + "console": "integratedTerminal", + "internalConsoleOptions": "neverOpen", + "sourceMaps": true, + "windows": { + "program": "${workspaceFolder}/rsconcept/frontend/node_modules/jest/bin/jest" + } + }, ] } \ No newline at end of file diff --git a/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx b/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx index 54f1600c..218c93f0 100644 --- a/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx +++ b/rsconcept/frontend/src/pages/RSFormPage/EditorTermGraph.tsx @@ -1,35 +1,64 @@ -import { useCallback, useMemo, useRef, useState } from 'react'; -import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, useSelection } from 'reagraph'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, Sphere, useSelection } from 'reagraph'; import Button from '../../components/Common/Button'; import Checkbox from '../../components/Common/Checkbox'; import ConceptSelect from '../../components/Common/ConceptSelect'; +import { ArrowsRotateIcon } from '../../components/Icons'; import { useRSForm } from '../../context/RSFormContext'; import { useConceptTheme } from '../../context/ThemeContext'; import useLocalStorage from '../../hooks/useLocalStorage'; import { resources } from '../../utils/constants'; +import { Graph } from '../../utils/Graph'; import { GraphLayoutSelector,mapLayoutLabels } from '../../utils/staticUI'; function EditorTermGraph() { const { schema } = useRSForm(); const { darkMode } = useConceptTheme(); const [ layout, setLayout ] = useLocalStorage('graph_layout', 'forceatlas2'); + + const [ filtered, setFiltered ] = useState(new Graph()); const [ orbit, setOrbit ] = useState(false); + const [ noHermits, setNoHermits ] = useLocalStorage('graph_no_hermits', true); + const [ noTransitive, setNoTransitive ] = useLocalStorage('graph_no_transitive', false); const graphRef = useRef(null); + useEffect(() => { + if (!schema) { + setFiltered(new Graph()); + return; + } + const graph = schema.graph.clone(); + if (noHermits) { + graph.removeIsolated(); + } + if (noTransitive) { + graph.transitiveReduction(); + } + setFiltered(graph); + }, [schema, noHermits, noTransitive]); + const nodes: GraphNode[] = useMemo(() => { - return schema?.items.map(cst => { - return { - id: String(cst.id), - label: (cst.term.resolved || cst.term.raw) ? `${cst.alias}: ${cst.term.resolved || cst.term.raw}` : cst.alias - }} - ) ?? []; - }, [schema?.items]); + const result: GraphNode[] = []; + if (!schema) { + return result; + } + filtered.nodes.forEach(node => { + const cst = schema.items.find(cst => cst.id === node.id); + if (cst) { + result.push({ + id: String(node.id), + label: cst.term.resolved ? `${cst.alias}: ${cst.term.resolved}` : cst.alias + }); + } + }); + return result; + }, [schema, filtered.nodes]); const edges: GraphEdge[] = useMemo(() => { const result: GraphEdge[] = []; let edgeID = 1; - schema?.graph.nodes.forEach(source => { + filtered.nodes.forEach(source => { source.outputs.forEach(target => { result.push({ id: String(edgeID), @@ -40,7 +69,7 @@ function EditorTermGraph() { }); }); return result; - }, [schema?.graph]); + }, [filtered.nodes]); const handleCenter = useCallback(() => { graphRef.current?.resetControls(); @@ -64,22 +93,36 @@ function EditorTermGraph() { return (<>
-
- { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }} - /> +
+
+
setOrbit(event.target.checked) }/> -
@@ -99,9 +142,12 @@ function EditorTermGraph() { onNodePointerOver={onNodePointerOver} onNodePointerOut={onNodePointerOut} cameraMode={ orbit ? 'orbit' : layout.includes('3d') ? 'rotate' : 'pan'} - layoutOverrides={ layout.includes('tree') ? { nodeLevelRatio: 1 } : undefined } + layoutOverrides={ layout.includes('tree') ? { nodeLevelRatio: 3 } : undefined } labelFontUrl={resources.graph_font} theme={darkMode ? darkTheme : lightTheme} + renderNode={({ node, ...rest }) => ( + + )} />
diff --git a/rsconcept/frontend/src/utils/Graph.test.ts b/rsconcept/frontend/src/utils/Graph.test.ts index 6f69b67d..80861ed8 100644 --- a/rsconcept/frontend/src/utils/Graph.test.ts +++ b/rsconcept/frontend/src/utils/Graph.test.ts @@ -20,6 +20,51 @@ describe('Testing Graph constuction', () => { expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]); expect([... graph.nodes.get(1)!.outputs]).toStrictEqual([2]); }); + + test('cloning', () => { + const graph = new Graph([[1, 2], [3], [4, 1]]); + const clone = graph.clone(); + expect([... graph.nodes.keys()]).toStrictEqual([... clone.nodes.keys()]); + expect([... graph.nodes.values()]).toStrictEqual([... clone.nodes.values()]); + + clone.removeNode(3); + expect(clone.nodes.get(3)).toBeUndefined(); + expect(graph.nodes.get(3)).not.toBeUndefined(); + }); +}); + +describe('Testing Graph editing', () => { + test('removing edges should not remove nodes', () => { + const graph = new Graph([[1, 2], [3], [4, 1]]); + expect(graph.hasEdge(4, 1)).toBeTruthy(); + + graph.removeEdge(5, 0); + graph.removeEdge(4, 1); + + expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]); + expect(graph.hasEdge(4, 1)).toBeFalsy(); + }); + + test('removing isolated nodes', () => { + const graph = new Graph([[9, 1], [9, 2], [2, 1], [4, 3], [5, 9], [7], [8]]); + graph.removeIsolated() + expect([... graph.nodes.keys()]).toStrictEqual([9, 1, 2, 4, 3, 5]); + }); + + test('transitive reduction', () => { + const graph = new Graph([[1, 3], [1, 2], [2, 3]]); + graph.transitiveReduction() + expect(graph.hasEdge(1, 2)).toBeTruthy(); + expect(graph.hasEdge(2, 3)).toBeTruthy(); + expect(graph.hasEdge(1, 3)).toBeFalsy(); + }); +}); + +describe('Testing Graph sort', () => { + test('topological order', () => { + const graph = new Graph([[9, 1], [9, 2], [2, 1], [4, 3], [5, 9]]); + expect(graph.tolopogicalOrder()).toStrictEqual([5, 4, 3, 9, 1, 2]); + }); }); describe('Testing Graph queries', () => { diff --git a/rsconcept/frontend/src/utils/Graph.ts b/rsconcept/frontend/src/utils/Graph.ts index 19b00f04..c060c3d9 100644 --- a/rsconcept/frontend/src/utils/Graph.ts +++ b/rsconcept/frontend/src/utils/Graph.ts @@ -10,6 +10,13 @@ export class GraphNode { this.inputs = []; } + clone(): GraphNode { + const result = new GraphNode(this.id); + result.outputs = [... this.outputs]; + result.inputs = [... this.inputs]; + return result; + } + addOutput(node: number): void { this.outputs.push(node); } @@ -45,6 +52,12 @@ export class Graph { }); } + clone(): Graph { + const result = new Graph(); + this.nodes.forEach(node => result.nodes.set(node.id, node.clone())); + return result; + } + addNode(target: number): GraphNode { let node = this.nodes.get(target); if (!node) { @@ -67,6 +80,16 @@ export class Graph { return nodeToRemove; } + removeIsolated(): GraphNode[] { + const result: GraphNode[] = []; + this.nodes.forEach(node => { + if (node.outputs.length === 0 && node.inputs.length === 0) { + this.nodes.delete(node.id); + } + }); + return result; + } + addEdge(source: number, destination: number): void { const sourceNode = this.addNode(source); const destinationNode = this.addNode(destination); @@ -83,6 +106,14 @@ export class Graph { } } + hasEdge(source: number, destination: number): boolean { + const sourceNode = this.nodes.get(source); + if (!sourceNode) { + return false; + } + return !!sourceNode.outputs.find(id => id === destination); + } + expandOutputs(origin: number[]): number[] { const result: number[] = []; const marked = new Map(); @@ -143,27 +174,63 @@ export class Graph { return result; } - visitDFS(visitor: (node: GraphNode) => void) { - const visited: Map = new Map(); + tolopogicalOrder(): number[] { + const result: number[] = []; + const marked = new Map(); this.nodes.forEach(node => { - if (!visited.has(node.id)) { - this.depthFirstSearch(node, visited, visitor); + if (marked.get(node.id)) { + return; } + const toVisit: number[] = [node.id]; + let index = 0; + while (toVisit.length > 0) { + const item = toVisit[index]; + if (marked.get(item)) { + if (!result.find(id => id ===item)) { + result.push(item); + } + toVisit.splice(index, 1); + index -= 1; + } else { + marked.set(item, true); + const itemNode = this.nodes.get(item); + if (itemNode && itemNode.outputs.length > 0) { + itemNode.outputs.forEach(child => { + if (!marked.get(child)) { + toVisit.push(child); + } + }); + } + if (index + 1 < toVisit.length) { + index += 1; + } + } + } + marked }); + return result.reverse(); } - private depthFirstSearch( - node: GraphNode, - visited: Map, - visitor: (node: GraphNode) => void) - : void { - visited.set(node.id, true); - visitor(node); - node.outputs.forEach((item) => { - if (!visited.has(item)) { - const childNode = this.nodes.get(item)!; - this.depthFirstSearch(childNode, visited, visitor); + transitiveReduction() { + const order = this.tolopogicalOrder(); + const marked = new Map(); + order.forEach(nodeID => { + if (marked.get(nodeID)) { + return; + } + const stack: {id: number, parents: number[]}[] = []; + stack.push({id: nodeID, parents: []}); + while (stack.length > 0) { + const item = stack.splice(0, 1)[0]; + const node = this.nodes.get(item.id); + if (node) { + node.outputs.forEach(child => { + item.parents.forEach(parent => this.removeEdge(parent, child)); + stack.push({id: child, parents: [item.id, ...item.parents]}) + }); + } + marked.set(item.id, true) } }); - } + } } diff --git a/rsconcept/frontend/src/utils/utils.tsx b/rsconcept/frontend/src/utils/utils.tsx index 9adcb45c..eb884f99 100644 --- a/rsconcept/frontend/src/utils/utils.tsx +++ b/rsconcept/frontend/src/utils/utils.tsx @@ -7,3 +7,11 @@ export function assertIsNode(e: EventTarget | null): asserts e is Node { export async function delay(ms: number) { return await new Promise(resolve => setTimeout(resolve, ms)); } + +export function trimString(target: string, maxLen: number): string { + if (target.length < maxLen) { + return target; + } else { + return target.substring(0, maxLen) + '...'; + } +} \ No newline at end of file