Add graph filtering for TermGraph

This commit is contained in:
IRBorisov 2023-08-03 16:42:49 +03:00
parent b8fe9953a9
commit 0c53bf7a30
5 changed files with 228 additions and 42 deletions

20
.vscode/launch.json vendored
View File

@ -52,6 +52,26 @@
"request": "launch", "request": "launch",
"script": "${workspaceFolder}/rsconcept/RunServer.ps1", "script": "${workspaceFolder}/rsconcept/RunServer.ps1",
"args": ["-freshStart"] "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"
} }
},
] ]
} }

View File

@ -1,35 +1,64 @@
import { useCallback, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, useSelection } from 'reagraph'; import { darkTheme, GraphCanvas, GraphCanvasRef, GraphEdge, GraphNode, LayoutTypes, lightTheme, Sphere, useSelection } from 'reagraph';
import Button from '../../components/Common/Button'; import Button from '../../components/Common/Button';
import Checkbox from '../../components/Common/Checkbox'; import Checkbox from '../../components/Common/Checkbox';
import ConceptSelect from '../../components/Common/ConceptSelect'; import ConceptSelect from '../../components/Common/ConceptSelect';
import { ArrowsRotateIcon } from '../../components/Icons';
import { useRSForm } from '../../context/RSFormContext'; import { useRSForm } from '../../context/RSFormContext';
import { useConceptTheme } from '../../context/ThemeContext'; import { useConceptTheme } from '../../context/ThemeContext';
import useLocalStorage from '../../hooks/useLocalStorage'; import useLocalStorage from '../../hooks/useLocalStorage';
import { resources } from '../../utils/constants'; import { resources } from '../../utils/constants';
import { Graph } from '../../utils/Graph';
import { GraphLayoutSelector,mapLayoutLabels } from '../../utils/staticUI'; import { GraphLayoutSelector,mapLayoutLabels } from '../../utils/staticUI';
function EditorTermGraph() { function EditorTermGraph() {
const { schema } = useRSForm(); const { schema } = useRSForm();
const { darkMode } = useConceptTheme(); const { darkMode } = useConceptTheme();
const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'forceatlas2'); const [ layout, setLayout ] = useLocalStorage<LayoutTypes>('graph_layout', 'forceatlas2');
const [ filtered, setFiltered ] = useState<Graph>(new Graph());
const [ orbit, setOrbit ] = useState(false); const [ orbit, setOrbit ] = useState(false);
const [ noHermits, setNoHermits ] = useLocalStorage('graph_no_hermits', true);
const [ noTransitive, setNoTransitive ] = useLocalStorage('graph_no_transitive', false);
const graphRef = useRef<GraphCanvasRef | null>(null); const graphRef = useRef<GraphCanvasRef | null>(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(() => { const nodes: GraphNode[] = useMemo(() => {
return schema?.items.map(cst => { const result: GraphNode[] = [];
return { if (!schema) {
id: String(cst.id), return result;
label: (cst.term.resolved || cst.term.raw) ? `${cst.alias}: ${cst.term.resolved || cst.term.raw}` : cst.alias }
}} filtered.nodes.forEach(node => {
) ?? []; const cst = schema.items.find(cst => cst.id === node.id);
}, [schema?.items]); 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 edges: GraphEdge[] = useMemo(() => {
const result: GraphEdge[] = []; const result: GraphEdge[] = [];
let edgeID = 1; let edgeID = 1;
schema?.graph.nodes.forEach(source => { filtered.nodes.forEach(source => {
source.outputs.forEach(target => { source.outputs.forEach(target => {
result.push({ result.push({
id: String(edgeID), id: String(edgeID),
@ -40,7 +69,7 @@ function EditorTermGraph() {
}); });
}); });
return result; return result;
}, [schema?.graph]); }, [filtered.nodes]);
const handleCenter = useCallback(() => { const handleCenter = useCallback(() => {
graphRef.current?.resetControls(); graphRef.current?.resetControls();
@ -64,22 +93,36 @@ function EditorTermGraph() {
return (<> return (<>
<div className='relative w-full'> <div className='relative w-full'>
<div className='absolute top-0 left-0 z-20 px-3 py-2 w-[12rem] flex flex-col gap-2'> <div className='absolute top-0 left-0 z-20 py-2 w-[12rem] flex flex-col'>
<div className='flex items-center gap-1 w-[15rem]'>
<Button
icon={<ArrowsRotateIcon size={8} />}
dense
tooltip='Центрировать изображение'
widthClass='h-full'
onClick={handleCenter}
/>
<ConceptSelect <ConceptSelect
options={GraphLayoutSelector} options={GraphLayoutSelector}
placeholder='Выберите тип' placeholder='Выберите тип'
values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []} values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []}
onChange={data => { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }} onChange={data => { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }}
/> />
</div>
<Checkbox <Checkbox
label='Анимация вращения' label='Анимация вращения'
widthClass='w-full'
value={orbit} value={orbit}
onChange={ event => setOrbit(event.target.checked) }/> onChange={ event => setOrbit(event.target.checked) }
<Button />
text='Центрировать' <Checkbox
dense label='Удалить несвязанные'
onClick={handleCenter} value={noHermits}
onChange={ event => setNoHermits(event.target.checked) }
/>
<Checkbox
label='Транзитивная редукция'
value={noTransitive}
onChange={ event => setNoTransitive(event.target.checked) }
/> />
</div> </div>
</div> </div>
@ -99,9 +142,12 @@ function EditorTermGraph() {
onNodePointerOver={onNodePointerOver} onNodePointerOver={onNodePointerOver}
onNodePointerOut={onNodePointerOut} onNodePointerOut={onNodePointerOut}
cameraMode={ orbit ? 'orbit' : layout.includes('3d') ? 'rotate' : 'pan'} 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} labelFontUrl={resources.graph_font}
theme={darkMode ? darkTheme : lightTheme} theme={darkMode ? darkTheme : lightTheme}
renderNode={({ node, ...rest }) => (
<Sphere {...rest} node={node} />
)}
/> />
</div> </div>
</div> </div>

View File

@ -20,6 +20,51 @@ describe('Testing Graph constuction', () => {
expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]); expect([... graph.nodes.keys()]).toStrictEqual([1, 2, 3, 4]);
expect([... graph.nodes.get(1)!.outputs]).toStrictEqual([2]); 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', () => { describe('Testing Graph queries', () => {

View File

@ -10,6 +10,13 @@ export class GraphNode {
this.inputs = []; this.inputs = [];
} }
clone(): GraphNode {
const result = new GraphNode(this.id);
result.outputs = [... this.outputs];
result.inputs = [... this.inputs];
return result;
}
addOutput(node: number): void { addOutput(node: number): void {
this.outputs.push(node); 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 { addNode(target: number): GraphNode {
let node = this.nodes.get(target); let node = this.nodes.get(target);
if (!node) { if (!node) {
@ -67,6 +80,16 @@ export class Graph {
return nodeToRemove; 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 { addEdge(source: number, destination: number): void {
const sourceNode = this.addNode(source); const sourceNode = this.addNode(source);
const destinationNode = this.addNode(destination); 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[] { expandOutputs(origin: number[]): number[] {
const result: number[] = []; const result: number[] = [];
const marked = new Map<number, boolean>(); const marked = new Map<number, boolean>();
@ -143,26 +174,62 @@ export class Graph {
return result; return result;
} }
visitDFS(visitor: (node: GraphNode) => void) { tolopogicalOrder(): number[] {
const visited: Map<number, boolean> = new Map(); const result: number[] = [];
const marked = new Map<number, boolean>();
this.nodes.forEach(node => { this.nodes.forEach(node => {
if (!visited.has(node.id)) { if (marked.get(node.id)) {
this.depthFirstSearch(node, visited, visitor); 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( transitiveReduction() {
node: GraphNode, const order = this.tolopogicalOrder();
visited: Map<number, boolean>, const marked = new Map<number, boolean>();
visitor: (node: GraphNode) => void) order.forEach(nodeID => {
: void { if (marked.get(nodeID)) {
visited.set(node.id, true); return;
visitor(node); }
node.outputs.forEach((item) => { const stack: {id: number, parents: number[]}[] = [];
if (!visited.has(item)) { stack.push({id: nodeID, parents: []});
const childNode = this.nodes.get(item)!; while (stack.length > 0) {
this.depthFirstSearch(childNode, visited, visitor); 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)
} }
}); });
} }

View File

@ -7,3 +7,11 @@ export function assertIsNode(e: EventTarget | null): asserts e is Node {
export async function delay(ms: number) { export async function delay(ms: number) {
return await new Promise(resolve => setTimeout(resolve, ms)); 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) + '...';
}
}