mirror of
https://github.com/IRBorisov/ConceptPortal.git
synced 2025-06-26 13:00:39 +03:00
Add graph filtering for TermGraph
This commit is contained in:
parent
b8fe9953a9
commit
0c53bf7a30
22
.vscode/launch.json
vendored
22
.vscode/launch.json
vendored
|
@ -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"
|
||||||
|
}
|
||||||
|
},
|
||||||
]
|
]
|
||||||
}
|
}
|
|
@ -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'>
|
||||||
<ConceptSelect
|
<div className='flex items-center gap-1 w-[15rem]'>
|
||||||
options={GraphLayoutSelector}
|
<Button
|
||||||
placeholder='Выберите тип'
|
icon={<ArrowsRotateIcon size={8} />}
|
||||||
values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []}
|
dense
|
||||||
onChange={data => { setLayout(data.length > 0 ? data[0].value : GraphLayoutSelector[0].value); }}
|
tooltip='Центрировать изображение'
|
||||||
/>
|
widthClass='h-full'
|
||||||
|
onClick={handleCenter}
|
||||||
|
/>
|
||||||
|
<ConceptSelect
|
||||||
|
options={GraphLayoutSelector}
|
||||||
|
placeholder='Выберите тип'
|
||||||
|
values={layout ? [{ value: layout, label: mapLayoutLabels.get(layout) }] : []}
|
||||||
|
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>
|
||||||
|
|
|
@ -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', () => {
|
||||||
|
|
|
@ -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)
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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) + '...';
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user